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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -96,6 +96,10 @@ struct TrashLaneView: View {
|
||||
/// Styling), which is exactly the case the setting exists for (`Accommodations.Wash`).
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
|
||||
/// The window's appearance — 10-accessibility.md's runtime-contrast input, read in `body` so a
|
||||
/// light/dark flip recomputes the header's ink (`LaneView.colorScheme`'s twin, for its reason).
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
/// The live body metric — this column's geometry is `LaneView`'s, derived from the same font
|
||||
/// (`BoardMetrics`), because these are the same cards in a column that must read as their
|
||||
/// sibling.
|
||||
@@ -226,6 +230,25 @@ struct TrashLaneView: View {
|
||||
))
|
||||
}
|
||||
}
|
||||
// **The board's computed contrast ink** (10-accessibility.md; `BoardTextInk`) — the trash
|
||||
// header is the second and last place on the board where text sits on the user's colour,
|
||||
// whether that colour came from the palette grid or from a hand-written hex.
|
||||
//
|
||||
// The wash between the text and the board background does not save it: `.quaternary` at 50%
|
||||
// is a ~5%-alpha tint (`Accommodations.trashHeaderWash`), so the glyphs are effectively on
|
||||
// the board colour, and the decision is deliberately taken against that colour rather than
|
||||
// against a three-layer composite — modelling a tint that shifts the surface by a couple of
|
||||
// luminance points would add a whole layer of arithmetic to move no decision.
|
||||
//
|
||||
// **Outside the `.background`, unlike the lane's**, so the wash and the hatch flip with the
|
||||
// text: both are drawn in `.quaternary` — the same label vocabulary — and a hatch left in the
|
||||
// window's appearance over an inverted board is a dark texture on a dark surface, which
|
||||
// would quietly cost the trash the *non-colour* distinction 10-accessibility.md's
|
||||
// "state is never colour-alone" requires of it.
|
||||
.boardTextInk(BoardTextInk.scheme(
|
||||
forBoardBackground: store.snapshot.background,
|
||||
appearance: colorScheme
|
||||
))
|
||||
// The container carries the label and the count (see `body`), so the header itself is
|
||||
// decoration for VoiceOver rather than a second element saying the same thing.
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Reference in New Issue
Block a user