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
+31 -13
View File
@@ -90,12 +90,23 @@ struct TrashLaneView: View {
/// specifically ("and trash animations all get reduced variants").
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Reduce Transparency, for the column's two washes below "glass underlays go solid, wherever
/// they appear" (10-accessibility.md). The trash plate and its hatched header are the board's
/// only translucent surfaces, and they sit over a background the *user* chose (03-board-ui.md §
/// Styling), which is exactly the case the setting exists for (`Accommodations.Wash`).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
/// 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.
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
/// The lane plate's corner radius matched to `LaneView`'s so the column reads as a sibling of
/// the lanes rather than as a different kind of object.
private let cornerRadius: CGFloat = 10
private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }
/// Between the cards `LaneView.cardSpacing`, because these are the same cards.
private let cardSpacing: CGFloat = 8
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -104,7 +115,7 @@ struct TrashLaneView: View {
}
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.quaternary.opacity(0.35))
.fill(Accommodations.trashPlateWash(reduceTransparency: reduceTransparency).style)
)
// **The delete gesture's drop target**, over the whole column the header included, since
// the ruling is "dropping a live card on the shown trash", not on one of its rows. It
@@ -186,7 +197,7 @@ struct TrashLaneView: View {
/// It carries no gesture at all: no selection (the column "is never selectable as a lane"), no
/// reorder drag, no context menu.
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
Image(systemName: "trash")
.foregroundStyle(.secondary)
.imageScale(.medium)
@@ -198,13 +209,16 @@ struct TrashLaneView: View {
countBadge
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.padding(.horizontal, BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: pointSize))
.padding(.vertical, BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: pointSize))
.background {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
.fill(.quaternary.opacity(0.5))
.fill(Accommodations.trashHeaderWash(reduceTransparency: reduceTransparency).style)
.overlay {
DiagonalHatch()
// The hatch pitch scales with the text (`BoardMetrics.trashHatchSpacing`): this
// is the trash's **non-colour** distinction, and a fixed 7pt pitch behind a
// headline twice its usual size reads as a texture rather than as hatching.
DiagonalHatch(spacing: BoardMetrics.trashHatchSpacing(bodyPointSize: pointSize))
.stroke(.quaternary, lineWidth: 1)
.clipShape(UnevenRoundedRectangle(
topLeadingRadius: cornerRadius,
@@ -224,8 +238,8 @@ struct TrashLaneView: View {
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
.padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
.background(Capsule().fill(.quaternary))
}
@@ -274,7 +288,7 @@ struct TrashLaneView: View {
// The delete gesture's shadow, holding the topmost row open
// (04-interactions.md The trash). At the nominal card height: the cards
// being proposed have no face here yet to be measured.
DragShadow(cornerRadius: CardFaceMetrics.cornerRadius)
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.frame(maxWidth: .infinity)
.frame(height: LaneDropRegistry.nominalCardHeight)
}
@@ -306,7 +320,7 @@ struct TrashLaneView: View {
// column's gesture surface is full height" (04-interactions.md The trash, settled)
// needs that blank space to actually belong to the view the gesture below is on.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(6)
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
.contentShape(Rectangle())
// The band's trash-side surface. It arms from the column's empty space, full height
// (above) included, so a drag can start from the blank area below the last card exactly
@@ -354,8 +368,12 @@ private enum TrashSlot: Identifiable {
///
/// The lines start a full header-height to the left of the leading edge so the first stroke reaches
/// the top-left corner instead of beginning partway across.
///
/// The pitch is the caller's (`BoardMetrics.trashHatchSpacing`), because it scales with the text:
/// the hatch is the trash's non-colour distinction (10-accessibility.md's never-colour-alone rule),
/// and a distinction that stops reading at a large text size is not one.
private struct DiagonalHatch: Shape {
var spacing: CGFloat = 7
var spacing: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()