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
+55 -20
View File
@@ -335,26 +335,50 @@ struct CardStyleAnchorTests {
@Suite("Card sidebar ▸ style editor layout")
struct StyleEditorLayoutTests {
/// How wide `columns` wells and the gaps between them actually draw.
private func gridWidth(columns: Int) -> CGFloat {
CGFloat(columns) * StyleEditorLayout.wellSide + CGFloat(columns - 1) * StyleEditorLayout.wellSpacing
/// How wide `columns` wells and the gaps between them actually draw, at a given text size.
private func gridWidth(columns: Int, bodyPointSize: CGFloat) -> CGFloat {
CGFloat(columns) * StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
+ CGFloat(columns - 1) * StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
}
@Test("The popover anchor is unchanged by the sidebar's arrival")
@Test("The popover anchor keeps its settled geometry at the standard text size")
func thePopoverKeepsItsSettledGeometry() {
// 268 points and 7 + 6 background wells are 03-board-ui.md's own numbers ("narrow enough to
// sit beside a card"), and the two anchors that were here first must not have moved because a
// third one needed different ones.
#expect(StyleEditorLayout.popover.width == 268)
#expect(StyleEditorLayout.popover.padding == 14)
#expect(StyleEditorLayout.popover.backgroundColumns == 7)
#expect(StyleEditorLayout.popover.symbolColumns == 8)
#expect(StyleEditorLayout.popover.symbolGridMaximumHeight == 168)
// sit beside a card"). Making the anchor font-derived (10-accessibility.md Text scaling)
// must not have moved them at the size they were settled for the point of the multiples is
// that the default text size renders exactly what it always did.
let popover = StyleEditorLayout.popover(bodyPointSize: 13)
#expect(popover.width == 268)
#expect(popover.padding == 14)
#expect(popover.backgroundColumns == 7)
#expect(popover.symbolColumns == 8)
#expect(popover.symbolGridMaximumHeight == 168)
#expect(popover.wellSide == 20)
#expect(popover.wellSpacing == 6)
}
/// The other half of the same claim: **the popover grows with the text**, so its seven wells
/// still fit at a large system text size instead of overflowing a frame frozen at 268 points.
@Test("The popover's frame grows with the text, and its wells keep fitting inside it")
func thePopoverScales() {
var previousWidth: CGFloat = 0
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let popover = StyleEditorLayout.popover(bodyPointSize: size)
#expect(popover.width! > previousWidth, "the popover must widen with the text at \(size)pt")
previousWidth = popover.width!
// The column counts are the design's and never move; what has to hold is that they
// still fit, insets included.
#expect(popover.backgroundColumns == 7)
let content = popover.width! - 2 * popover.padding
#expect(gridWidth(columns: 7, bodyPointSize: size) <= content, "7 wells overflow at \(size)pt")
#expect(gridWidth(columns: 8, bodyPointSize: size) <= content, "8 wells overflow at \(size)pt")
}
}
@Test("The sidebar anchor takes the column it is given and adds nothing to it")
func theSidebarBringsNoGeometryOfItsOwn() {
let layout = StyleEditorLayout.sidebar(contentWidth: 169)
let layout = StyleEditorLayout.sidebar(contentWidth: 169, bodyPointSize: 13)
// No width: the sidebar's is `CardWindowMetrics`' one decision. No padding: the section stack
// is already inset by a gutter, and insetting twice would narrow the grids for nothing.
@@ -369,15 +393,22 @@ struct StyleEditorLayoutTests {
func theGridsFitTheSidebar() {
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
let available = CardWindowMetrics.sidebarContentWidth(bodyPointSize: size)
let layout = StyleEditorLayout.sidebar(contentWidth: available)
let layout = StyleEditorLayout.sidebar(contentWidth: available, bodyPointSize: size)
#expect(layout.backgroundColumns == layout.symbolColumns, "one column count for one column")
// Fits a grid wider than its column puts the last well of every row out of reach of
// the pointer, at a text size nobody checks by eye.
#expect(gridWidth(columns: layout.backgroundColumns) <= available, "overflows at \(size)pt")
#expect(gridWidth(columns: layout.backgroundColumns, bodyPointSize: size) <= available,
"overflows at \(size)pt")
// And is maximal: one more well would not have fitted, so the wells are as large a set as
// the column can show rather than an arbitrary count that happened to be safe.
#expect(gridWidth(columns: layout.backgroundColumns + 1) > available, "under-packed at \(size)pt")
#expect(gridWidth(columns: layout.backgroundColumns + 1, bodyPointSize: size) > available,
"under-packed at \(size)pt")
// And the count is *stable* across text sizes, because the column and the wells scale on
// the same ruler the sidebar is 26 body characters wide and a well is 1.55 body ems,
// and neither of those ratios moves. Without that the grid would collapse to a strip at
// a large text size, which is a palette the user can no longer scan.
#expect(layout.backgroundColumns >= 4, "the grid collapsed to a strip at \(size)pt")
}
}
@@ -385,8 +416,11 @@ struct StyleEditorLayoutTests {
func theSidebarIsTheNarrowerAnchor() {
// Which is the entire reason this type exists: the popover's 7 wells across do not fit a
// 26-character column, so an editor with one hard-coded frame could not have both anchors.
let layout = StyleEditorLayout.sidebar(contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13))
#expect(layout.backgroundColumns < StyleEditorLayout.popover.backgroundColumns)
let layout = StyleEditorLayout.sidebar(
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13),
bodyPointSize: 13
)
#expect(layout.backgroundColumns < StyleEditorLayout.popover(bodyPointSize: 13).backgroundColumns)
#expect(layout.backgroundColumns >= 4, "a grid this narrow would be a strip, not a palette")
}
@@ -395,9 +429,10 @@ struct StyleEditorLayoutTests {
// Total over any width, because the caller is a layout system: a zero proposal during a
// window's first frame must not produce a grid of zero columns, which is a division by zero
// waiting in `LazyVGrid`.
#expect(StyleEditorLayout.columns(fitting: 0) == 1)
#expect(StyleEditorLayout.columns(fitting: -100) == 1)
#expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide) == 1)
#expect(StyleEditorLayout.columns(fitting: 0, bodyPointSize: 13) == 1)
#expect(StyleEditorLayout.columns(fitting: -100, bodyPointSize: 13) == 1)
#expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide(bodyPointSize: 13),
bodyPointSize: 13) == 1)
}
@Test("The sidebar's content width is the column minus its two gutters")