Implement visual accommodations and Full Keyboard Access

Full relative text scaling per DESIGN/10: BoardMetrics is the board
strip's geometry as a pure function of the body point size
(CardWindowMetrics' twin) — lane plate/header/band, card
corner/stripe/padding, masonry spacing, the drop model's nominal card
height, resize-handle geometry, trash hatch pitch, and both window
floors all derive from an em; CardFaceMetrics folded in. The two fixed
font sizes (welcome brand/glyph) went relative; the toolbar search
field is 17 ems like the transient bar's. The no-horizontal-scroll
invariant is pinned by test at six text sizes by twelve lane counts.

Accommodations is Motion's sibling for the visual settings: Increase
Contrast adds a flat point to strokes (monotone, hierarchy-preserving),
gives borderless card/lane plates a resting separator hairline, and
takes faded accents to full alpha; Reduce Transparency turns the
transient search bar's glass solid and does the same for the alpha
washes that composite over a user-chosen board background (trash plate,
hatched header, drag shadow). Reduce Motion audited — every animated
surface already routes through Motion with a reduced variant; no gaps.

Full Keyboard Access: the template chooser's tiles were pointer-only —
now focusable, arrow-navigable (clamped, StyleWellGrid's rule), Space
picks, Return stays the sheet's default action, focus names the
selection one-way. The board's single tab stop shows its focus ring
under FKA (focusEffectDisabled inverts). Style editor verified already
conformant. Edge accents verified text-free; trash hatch pitch now
font-derived so it still reads as hatching at large text.

1549 unit tests green, both schemes build.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 08:48:25 -04:00
parent c339b4cecf
commit 8564814754
21 changed files with 1419 additions and 218 deletions
+302
View File
@@ -0,0 +1,302 @@
import CoreGraphics
import SwiftUI
import Testing
@testable import Kanban
/// The two seams m11's visual-accommodations card added, and the one it made font-derived
/// (10-accessibility.md Text scaling & visual accommodations):
///
/// 1. **`BoardMetrics`** the board strip's geometry as a pure function of the body point size, so
/// "Card face, lane header, and masonry metrics derive from font metrics, so layout survives the
/// largest system text sizes" is a fact a suite can assert rather than something to be verified by
/// eye at three text sizes. Nothing here renders; what is pinned is that the numbers *move*, that
/// they move in the right direction, and that they land on the board's settled figures at the
/// standard 13pt body which is the whole claim that making the board scale did not redesign it.
/// 2. **`Accommodations`** Increase Contrast and Reduce Transparency as decisions separable from
/// the drawing, `Motion`'s pattern for its reason (`AnyShapeStyle` is opaque, so a claim about a
/// fill is only testable through a small `Equatable` enum).
/// 3. **`LaneLayoutMath` under a scaled gap** the no-horizontal-scroll invariant, re-asserted at
/// the text sizes the gap now varies over. It is 03-board-ui.md's headline layout rule and the one
/// thing a font-derived gap could plausibly have broken.
// MARK: - The board's font-derived geometry
@Suite("Board metrics ▸ the standard text size is unchanged")
struct BoardMetricsSettledFiguresTests {
/// The system body font is 13pt at the standard macOS text size, and every multiple in
/// `BoardMetrics` was chosen to reproduce the board's existing numbers there.
///
/// This is the load-bearing test of the whole conversion: the milestone's mandate was to make the
/// board *scale*, not to move it, so a default-text-size board must lay out on exactly the
/// figures it laid out on before. A drift here is a visual regression nothing else would catch.
@Test("Every figure lands on the board's settled number at 13pt")
func theStandardSizeReproducesTheSettledFigures() {
let size: CGFloat = 13
#expect(BoardMetrics.stripGap(bodyPointSize: size) == 12)
#expect(BoardMetrics.laneCornerRadius(bodyPointSize: size) == 10)
#expect(BoardMetrics.lanePlatePadding(bodyPointSize: size) == 6)
#expect(BoardMetrics.laneStackSpacing(bodyPointSize: size) == 8)
#expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6)
#expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5)
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) == 22)
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
#expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
#expect(BoardMetrics.cardRowSpacing(bodyPointSize: size) == 6)
#expect(BoardMetrics.cardSpacing(bodyPointSize: size) == 8)
#expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) == 44)
#expect(BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: size) == 10)
#expect(BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: size) == 8)
#expect(BoardMetrics.trashHatchSpacing(bodyPointSize: size) == 7)
#expect(BoardMetrics.resizeHandleWidth(bodyPointSize: size) == 12)
#expect(BoardMetrics.resizeHandleOverhang(bodyPointSize: size) == 8)
}
/// The board window's floor is the one 02/03 never named in points but that the host has always
/// carried 640 × 400, reproduced at the standard size.
@Test("The window minimum reproduces its settled size at 13pt")
func theWindowMinimumIsUnchanged() {
let minimum = BoardMetrics.windowMinimumSize(bodyPointSize: 13)
#expect(minimum.width == 637)
#expect(minimum.height == 403)
}
}
@Suite("Board metrics ▸ everything scales")
struct BoardMetricsScalingTests {
/// The text sizes the suite sweeps: below the standard, the standard, and the range a user who
/// has turned the system text size up actually lands in.
private static let sizes: [CGFloat] = [11, 13, 16, 18, 24, 36]
/// **The whole point of the type.** Every figure grows with the text size which is what
/// "layout survives the largest system text sizes" means arithmetically: a card's padding cannot
/// stay at 10 points while the title inside it doubles.
///
/// Two claims, because rounding to whole points makes them different claims. **Never smaller**
/// between adjacent sizes a figure that shrank as the text grew would be a bug and
/// **strictly larger** across the whole range, which is what separates a real derivation from a
/// fixed point size wearing a function's clothes. Adjacent sizes may legitimately share a value
/// for the smallest figures (0.3 em of a 16pt body and of an 18pt body both round to 5), and
/// that is the rounding doing its job: a hairline has no fractional setting worth having.
@Test("Every metric grows with the body point size")
func everyMetricIsMonotone() {
let metrics: [(String, (CGFloat) -> CGFloat)] = [
("stripGap", { BoardMetrics.stripGap(bodyPointSize: $0) }),
("laneCornerRadius", { BoardMetrics.laneCornerRadius(bodyPointSize: $0) }),
("lanePlatePadding", { BoardMetrics.lanePlatePadding(bodyPointSize: $0) }),
("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }),
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }),
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
("badgeHorizontalPadding", { BoardMetrics.badgeHorizontalPadding(bodyPointSize: $0) }),
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
("cardRowSpacing", { BoardMetrics.cardRowSpacing(bodyPointSize: $0) }),
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
("cardReplicaWidth", { BoardMetrics.cardReplicaWidth(bodyPointSize: $0) }),
("replicaPadding", { BoardMetrics.replicaPadding(bodyPointSize: $0) }),
("trashHeaderHorizontalPadding", { BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: $0) }),
("trashHatchSpacing", { BoardMetrics.trashHatchSpacing(bodyPointSize: $0) }),
("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }),
]
for (name, metric) in metrics {
for (smaller, larger) in zip(Self.sizes, Self.sizes.dropFirst()) {
#expect(metric(smaller) <= metric(larger),
"\(name) shrank from \(smaller)pt to \(larger)pt")
}
#expect(metric(Self.sizes.first!) < metric(Self.sizes.last!),
"\(name) is fixed across the whole range")
}
}
/// **The new-card button's reserve stays ahead of the header's own furniture.**
///
/// 03-board-ui.md's graceful-truncation rule says a long lane title truncates rather than
/// colliding with the button, and the reserve is what makes that true. It has to stay wider than
/// a glyph-and-margin at every size, which is the fixed-22pt failure this conversion exists to
/// remove: at 24pt the glyph alone approaches the old reserve.
@Test("The header's button reserve stays wider than the glyph it reserves for")
func theButtonReserveOutgrowsItsGlyph() {
for size in Self.sizes {
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) > size,
"the reserve is narrower than one em at \(size)pt")
#expect(
BoardMetrics.newCardButtonReserve(bodyPointSize: size)
> BoardMetrics.laneHeaderSpacing(bodyPointSize: size),
"the reserve is narrower than the header's own spacing at \(size)pt"
)
}
}
/// The card plate's proportions hold at every size the stripe stays a stripe rather than
/// becoming a band, and the plate's inset stays wider than the stripe it sits beside (which is
/// what keeps "colouring a card never shifts its title" from becoming "colouring a card eats its
/// title").
@Test("The card plate keeps its proportions at every text size")
func theCardPlateKeepsItsProportions() {
for size in Self.sizes {
let stripe = BoardMetrics.cardStripeWidth(bodyPointSize: size)
let padding = BoardMetrics.cardContentPadding(bodyPointSize: size)
let radius = BoardMetrics.cardCornerRadius(bodyPointSize: size)
#expect(stripe < padding, "the stripe is wider than the plate's inset at \(size)pt")
#expect(stripe < radius, "the stripe is wider than the corner it rounds into at \(size)pt")
#expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) > 2 * padding + size,
"the nominal height cannot hold one line of title at \(size)pt")
}
}
/// `em` is total and never yields a non-positive length: a zero-width stripe or a zero-height
/// band is a shape SwiftUI is asked to draw and cannot, and the pathological inputs (a
/// degenerate point size, a vanishing multiple) have to land somewhere.
@Test("The unit is floored at one point, whatever it is handed")
func theUnitIsTotal() {
#expect(BoardMetrics.em(0.3, bodyPointSize: 0) == 1)
#expect(BoardMetrics.em(0, bodyPointSize: 13) == 1)
#expect(BoardMetrics.em(-1, bodyPointSize: 13) == 1)
#expect(BoardMetrics.em(0.01, bodyPointSize: 13) == 1)
}
}
// MARK: - The no-horizontal-scroll invariant, under a gap that moves
@Suite("Board metrics ▸ the strip still never scrolls")
struct ScaledStripDivisionTests {
/// **The invariant 03-board-ui.md § Layout full visibility is built on, re-checked now that
/// the gap varies with the text size.**
///
/// The whole strip `totalUnits` standard widths plus `totalUnits + 1` gaps must fill the
/// window's width exactly, at every text size and every lane count. A larger text size therefore
/// buys a larger gap out of the lanes' own width: the lanes compress, the strip does not grow,
/// and horizontal scroll never appears. That is "the degenerate case is accepted, not floored",
/// holding through the scaling change rather than despite it.
@Test("The lanes plus the gaps fill the window exactly at every text size")
func theDivisionIsExactAtEveryTextSize() {
let stripWidth: CGFloat = 1400
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let gap = BoardMetrics.stripGap(bodyPointSize: size)
for units in 1...12 {
let standard = LaneLayoutMath.standardWidth(
stripWidth: stripWidth, totalUnits: units, gap: gap)
let drawn = standard * CGFloat(units) + gap * CGFloat(units + 1)
#expect(abs(drawn - stripWidth) < 0.001,
"\(units) units at \(size)pt drew \(drawn) into \(stripWidth)")
}
}
}
/// The same window and the same lane count: a **larger** text size means **narrower** lanes,
/// never a wider strip. This is the direction the invariant depends on the alternative would be
/// a strip that overflowed its window and had to scroll.
@Test("A larger text size narrows the lanes rather than widening the strip")
func aLargerTextSizeCompressesTheLanes() {
let stripWidth: CGFloat = 1400
var previous = CGFloat.greatestFiniteMagnitude
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let standard = LaneLayoutMath.standardWidth(
stripWidth: stripWidth,
totalUnits: 5,
gap: BoardMetrics.stripGap(bodyPointSize: size)
)
#expect(standard < previous, "lanes did not compress at \(size)pt")
previous = standard
}
}
}
// MARK: - Increase Contrast
@Suite("Accommodations ▸ Increase Contrast")
struct IncreaseContrastTests {
/// "Increase Contrast strengthens borders and the selection indicator" (10-accessibility.md), as
/// one flat point rather than a factor see `Accommodations.borderWidth` for why.
@Test("A stroke goes one point heavier under Increase Contrast")
func aStrokeGoesHeavier() {
for base in [1.0, 1.5, 2.0, 2.5, 3.0] as [CGFloat] {
#expect(Accommodations.borderWidth(base, contrast: .standard) == base)
#expect(Accommodations.borderWidth(base, contrast: .increased) == base + 1)
}
}
/// The widths encode a hierarchy a hovered card reads heavier than a selected one, a selected
/// tile heavier than an unselected one and the setting must not flatten it. A flat addend is
/// order-preserving; a multiplier applied to only some sites would not have been.
@Test("Strengthening preserves the hierarchy the widths encode")
func strengtheningIsOrderPreserving() {
let ordered: [CGFloat] = [1, 1.5, 2, 2.5, 3]
for contrast in [ColorSchemeContrast.standard, .increased] {
let strengthened = ordered.map { Accommodations.borderWidth($0, contrast: contrast) }
#expect(strengthened == strengthened.sorted(), "the order collapsed under \(contrast)")
#expect(Set(strengthened).count == ordered.count, "two widths merged under \(contrast)")
}
}
/// The half that is easy to miss: a card plate and a lane plate carry **no** resting border, so
/// "this is one card and that is another" is carried by a fill boundary alone exactly the
/// distinction the setting exists to rescue. Under Increase Contrast they gain one.
@Test("Resting plates gain an outline only under Increase Contrast")
func restingPlatesGainAnOutline() {
#expect(!Accommodations.drawsRestingBorder(contrast: .standard))
#expect(Accommodations.drawsRestingBorder(contrast: .increased))
}
/// Alpha is the other way a border can be weak. A width bump alone would leave the marquee's
/// 50%-alpha edge and the drag shadow's 55%-alpha dashes just as hard to see, two points wider.
@Test("A faded accent goes to full strength under Increase Contrast")
func afadedAccentGoesFull() {
for base in [0.5, 0.55, 0.6] {
#expect(Accommodations.accentOpacity(base, contrast: .standard) == base)
#expect(Accommodations.accentOpacity(base, contrast: .increased) == 1)
}
}
}
// MARK: - Reduce Transparency
@Suite("Accommodations ▸ Reduce Transparency")
struct ReduceTransparencyTests {
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board's one
/// surviving material is the transient search bar's `.bar` the design's own example, the card
/// face carousel's page dots, died with the carousel (03-board-ui.md § Card face).
@Test("The one glass underlay goes solid")
func glassGoesSolid() {
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
#expect(Accommodations.underlay(reduceTransparency: true) == .solid)
}
/// The washes are not glass they composite at an alpha rather than sampling a backdrop but
/// they fail the same way for the same user, because what is behind them is a colour the *user*
/// chose (03-board-ui.md § Styling). Each one goes opaque under the setting, and each keeps its
/// own weight without it: the trash header is heavier than its plate, because the header is the
/// whole of "you are looking at the trash".
@Test("Every translucent wash goes opaque, and keeps its weight otherwise")
func washesGoOpaque() {
#expect(Accommodations.trashPlateWash(reduceTransparency: true) == .opaque)
#expect(Accommodations.trashHeaderWash(reduceTransparency: true) == .opaque)
#expect(Accommodations.dragShadowWash(reduceTransparency: true) == .opaque)
#expect(Accommodations.trashPlateWash(reduceTransparency: false) == .translucent(opacity: 0.35))
#expect(Accommodations.trashHeaderWash(reduceTransparency: false) == .translucent(opacity: 0.5))
#expect(Accommodations.dragShadowWash(reduceTransparency: false) == .translucent(opacity: 0.5))
}
/// The trash column's two surfaces are a pair, and the pair has to stay legible as one: the
/// header must read as heavier than the plate under it, or the column stops announcing itself.
@Test("The trash header stays heavier than its plate")
func theTrashHeaderStaysHeavier() {
guard case let .translucent(header) = Accommodations.trashHeaderWash(reduceTransparency: false),
case let .translucent(plate) = Accommodations.trashPlateWash(reduceTransparency: false)
else {
Issue.record("both washes should be translucent without the setting")
return
}
#expect(header > plate)
}
}