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
+39 -10
View File
@@ -103,6 +103,9 @@ struct BoardView: View {
/// from the environment here and handed to `Motion`, which owns what "reduced" means for each.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
/// must go back to meaning create/rename rather than nothing at all.
@@ -110,7 +113,13 @@ struct BoardView: View {
/// The inter-lane gap, and the strip's outer margin one number, because the standard-width
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
private let spacing: CGFloat = 12
///
/// **Font-derived** (`BoardMetrics.stripGap`), which is what keeps the no-horizontal-scroll
/// invariant honest under 10-accessibility.md's full-relative-scaling rule: a larger system text
/// size widens the gap and therefore *narrows* every lane, since the window's width still divides
/// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the
/// degenerate case accepted, not floored" (03-board-ui.md § Layout full visibility).
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: BoardMetrics.bodyPointSize) }
var body: some View {
GeometryReader { proxy in
@@ -169,11 +178,20 @@ struct BoardView: View {
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
// the strip is the window's content, not a control, and a rectangle around the whole board
// would read as an error state.
// **The board is one tab stop** (10-accessibility.md Full Keyboard Access: "the board is
// one tab stop with arrow-key navigation within"), and this is it: one focusable strip,
// whose interior movement is the arrow grammar below rather than a focus stop per card.
// The controls *around* the cards each lane's new-card button, the popovers, the toolbar
// are ordinary `Button`s and stay Tab-reachable in their own right, which is the other half
// of the same sentence ("every control is Tab-reachable").
//
// The focus *ring* is normally off: the strip is the window's content, not a control, and a
// rectangle around the whole board would read as an error state. **Under Full Keyboard
// Access it comes back**, because that reasoning inverts completely there FKA's premise is
// that the user can see where Tab landed, and an invisible tab stop is not one
// (`Accommodations.isFullKeyboardAccessEnabled`).
.focusable()
.focusEffectDisabled()
.focusEffectDisabled(!Accommodations.isFullKeyboardAccessEnabled)
.focused($isBoardFocused)
.onAppear {
isBoardFocused = true
@@ -270,7 +288,7 @@ struct BoardView: View {
// One of the drag's N contiguous shadows, at the exact width the arriving lane
// will occupy its units measured against *this* strip's standard, which is
// what makes the drop land precisely where the shadow shows.
DragShadow()
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize))
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
.frame(maxHeight: .infinity)
}
@@ -335,7 +353,15 @@ struct BoardView: View {
Rectangle()
.fill(Color.accentColor.opacity(0.12))
.frame(width: rect.width, height: rect.height)
.overlay(Rectangle().strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 1))
// Increase Contrast takes the band's edge to full strength and a point heavier: the
// fill is a 12%-alpha wash by design, so the border is the only thing that says where
// the sweep actually reaches (`Accommodations`).
.overlay(
Rectangle().strokeBorder(
Color.accentColor.opacity(Accommodations.accentOpacity(0.5, contrast: contrast)),
lineWidth: Accommodations.borderWidth(1, contrast: contrast)
)
)
.offset(x: rect.minX, y: rect.minY)
.allowsHitTesting(false)
}
@@ -396,9 +422,12 @@ struct BoardView: View {
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
ZStack(alignment: .topLeading) {
if resizing {
DragShadow(dashed: false)
.frame(width: slotWidth)
.frame(maxHeight: .infinity)
DragShadow(
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize),
dashed: false
)
.frame(width: slotWidth)
.frame(maxHeight: .infinity)
}
// Interior columns follow the SNAPPED unit count while this lane is being resized a
// column count is integral, so it tracks k (which ticks and animates), not the live