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
+91 -32
View File
@@ -69,15 +69,24 @@ struct LaneView: View {
/// and handed to `Motion`, which owns what "reduced" means.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Increase Contrast, for the selection stroke and the plate's resting edge below
/// (10-accessibility.md). Read from the environment and handed to `Accommodations`, which owns
/// what "increased" does to a stroke.
@Environment(\.colorSchemeContrast) private var contrast
/// 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 }
/// Spacing between cards, and between the interior columns.
private let cardSpacing: CGFloat = 8
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
/// The lane plate's corner radius shared by the selection treatment and the accent band, whose
/// top corners round to exactly this so the band reads as the lane's own edge.
private let cornerRadius: CGFloat = 10
private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }
/// C7 · full-column top edge (03-board-ui.md § Styling Capabilities).
private let bandHeight: CGFloat = 5
private var bandHeight: CGFloat { BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize) }
/// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
/// tall as the strip gives it.
@@ -92,11 +101,11 @@ struct LaneView: View {
// lane's top edge, so it must sit outside the content inset rather than in it.
VStack(alignment: .leading, spacing: 0) {
accentBand
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
header
cardStack
}
.padding(6)
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
}
.background(selectionBackground)
.overlay(selectionStroke)
@@ -331,7 +340,7 @@ struct LaneView: View {
}
private var headerContent: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
.foregroundStyle(.secondary)
.imageScale(.medium)
@@ -340,9 +349,12 @@ struct LaneView: View {
Spacer(minLength: 0)
}
// Reserves the button's width so a long title truncates before it collides, and keeps the
// button out of the gestured region.
.padding(.trailing, 22)
.padding(.horizontal, 4)
// button out of the gestured region. **Font-derived** rather than a fixed 22pt: the button
// is an `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph
// itself at a large system text size and 03-board-ui.md's graceful-truncation rule would
// quietly stop holding (`BoardMetrics.newCardButtonReserve`).
.padding(.trailing, BoardMetrics.newCardButtonReserve(bodyPointSize: pointSize))
.padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
}
/// The title, or the rename editor when this lane is the one being renamed.
@@ -393,8 +405,8 @@ struct LaneView: 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))
}
@@ -485,7 +497,7 @@ struct LaneView: View {
replicaFace
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(12)
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
}
private var draggedLaneCount: Int {
@@ -497,11 +509,12 @@ struct LaneView: View {
private var replicaFace: some View {
VStack(alignment: .leading, spacing: 0) {
accentBand
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
headerContent
VStack(alignment: .leading, spacing: cardSpacing) {
ForEach(renderedCards.prefix(12)) { card in
HStack(alignment: .firstTextBaseline, spacing: 6) {
HStack(alignment: .firstTextBaseline,
spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(.secondary)
.imageScale(.medium)
@@ -510,16 +523,23 @@ struct LaneView: View {
.lineLimit(2)
Spacer(minLength: 0)
}
.padding(10)
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
.background(
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.fill(.background.secondary)
)
}
Spacer(minLength: 0)
}
}
.padding(6)
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
}
.frame(width: max(slotWidth, 80), height: max(measuredHeight, 120), alignment: .topLeading)
.frame(
width: max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize)),
height: max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize)),
alignment: .topLeading
)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
@@ -567,7 +587,7 @@ struct LaneView: View {
case let .shadow(_, height):
// One of the drag's N contiguous shadows, at the dragged card's frozen
// height the run's real footprint, so the drop lands exactly here.
DragShadow(cornerRadius: 8)
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.frame(height: height)
}
}
@@ -806,13 +826,33 @@ struct LaneView: View {
/// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as
/// a fill that would compete with it once that lands.
private var selectionBackground: some View {
RoundedRectangle(cornerRadius: 10)
RoundedRectangle(cornerRadius: cornerRadius)
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear))
}
/// The selection ring and, under Increase Contrast, the plate's resting edge as well
/// (10-accessibility.md: "Increase Contrast strengthens borders and the selection indicator";
/// `Accommodations`, and `CardFaceView.plateStroke` for the same three-way branch on a card).
///
/// A lane is otherwise bounded by nothing but the gap between it and its neighbour, which is the
/// distinction the setting most needs to restore here.
private var selectionStroke: some View {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
}
private var plateStroke: AnyShapeStyle {
if isSelected {
AnyShapeStyle(Color.accentColor)
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
AnyShapeStyle(.separator)
} else {
AnyShapeStyle(.clear)
}
}
private var plateStrokeWidth: CGFloat {
Accommodations.borderWidth(isSelected ? 1.5 : 1, contrast: contrast)
}
// MARK: - Rename plumbing
@@ -933,7 +973,7 @@ enum LaneSlot: Identifiable {
///
/// The editing face is an editor the accent-stroked well the user is typing into. The awaiting
/// face is **a card**: the same plate, inset, stripe gutter, icon, font and title position a default
/// new card gets (`CardFaceView`, via the `CardFaceMetrics` both read). That is the second half of
/// new card gets (`CardFaceView`, via the `BoardMetrics` both read). That is the second half of
/// 02-architecture.md TransientBoardState overlays' one-arrival rule the first half is
/// `LaneSlot` keying a committed placeholder by the arriving card's identity, which makes the echo
/// reload a content swap inside one persistent element; drawing that content identically is what
@@ -953,6 +993,13 @@ private struct NewCardStubView: View {
let openCard: (ItemID) -> Void
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
/// The live body metric the same one the real face reads, which is what makes "the numbers are
/// the same numbers rather than equal ones" survive the move to font-derived metrics.
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
var body: some View {
switch phase {
case .editing: editor
@@ -978,23 +1025,32 @@ private struct NewCardStubView: View {
)
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardFaceMetrics.contentPadding)
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.background(
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.fill(.background.secondary)
)
.overlay(
RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius)
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
// Increase Contrast takes the well to full strength and a point heavier a 60%-alpha
// outline is exactly the kind of border the setting exists to rescue
// (`Accommodations`).
.strokeBorder(
Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast)),
lineWidth: Accommodations.borderWidth(1.5, contrast: contrast)
)
)
}
/// **The arriving card, drawn a round trip early.** Every line below has a counterpart in
/// `CardFaceView.body`/`titleRow`, and the numbers are the same numbers rather than equal ones
/// (`CardFaceMetrics`) when the echo reload swaps this view for the real face inside the one
/// (`BoardMetrics`) when the echo reload swaps this view for the real face inside the one
/// slot they share, nothing about the plate, the icon, the font or the title's position changes.
///
/// No stripe overlay and no selection stroke: both would be `.clear` for a default, unselected
/// new card, and a shape that paints nothing is better left unwritten than written and disabled.
private var arrivingFace: some View {
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.card)
.foregroundStyle(.secondary)
.imageScale(.medium)
@@ -1004,9 +1060,12 @@ private struct NewCardStubView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardFaceMetrics.contentPadding)
.padding(.leading, CardFaceMetrics.stripeWidth)
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.padding(.leading, BoardMetrics.cardStripeWidth(bodyPointSize: pointSize))
.background(
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.fill(.background.secondary)
)
}
/// Commits, then **re-selects the lane** "Return commits and re-selects the lane (next Return