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
+50 -15
View File
@@ -38,6 +38,20 @@ struct WelcomeView: View {
/// Whether the recents list holds the keyboard, so Return can mean "open the selected row".
@FocusState private var listFocused: Bool
// MARK: - Geometry
//
// Every figure this window lays out on, as a multiple of the body font `BoardMetrics`' rule
// applied to the welcome window (10-accessibility.md Text scaling & visual accommodations). At
// the standard 13pt body they reproduce the numbers welcome has always drawn.
@MainActor static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
@MainActor static var brandingColumnWidth: CGFloat { BoardMetrics.em(23, bodyPointSize: pointSize) }
@MainActor static var brandingInset: CGFloat { BoardMetrics.em(2.5, bodyPointSize: pointSize) }
@MainActor static var appIconSide: CGFloat { BoardMetrics.em(7.4, bodyPointSize: pointSize) }
@MainActor static var minimumWidth: CGFloat { BoardMetrics.em(58.5, bodyPointSize: pointSize) }
@MainActor static var minimumHeight: CGFloat { BoardMetrics.em(35.4, bodyPointSize: pointSize) }
private var derivation: WelcomeRow.Derivation {
WelcomeRow.derive(recents: appModel.recents, failures: appModel.launchFailures)
}
@@ -49,16 +63,20 @@ struct WelcomeView: View {
var body: some View {
HStack(spacing: 0) {
branding
.frame(width: 300)
.frame(width: Self.brandingColumnWidth)
.frame(maxHeight: .infinity)
.padding(32)
.padding(Self.brandingInset)
Divider()
recents
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 760, minHeight: 460)
// Font-derived, like the board window's floor (10-accessibility.md Text scaling: "no fixed
// point sizes"): at a large system text size a 300-point branding column would clip the app
// name it exists to show, and a 760 × 460 floor would leave the recents list too narrow for
// the three lines each row carries. At the standard body size these are those numbers.
.frame(minWidth: Self.minimumWidth, minHeight: Self.minimumHeight)
// The window has no title bar, so the background is the drag handle. `.gesture` rather than
// `.highPriorityGesture`: a click on a row or a button belongs to the row or the button.
.gesture(WindowDragGesture())
@@ -76,12 +94,16 @@ struct WelcomeView: View {
VStack(alignment: .leading, spacing: 0) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 96, height: 96)
.frame(width: Self.appIconSide, height: Self.appIconSide)
.accessibilityHidden(true)
Text("Lanework")
.font(.system(size: 34, weight: .light))
.padding(.top, 12)
// A **relative** style, not a 34pt literal "relative text styles everywhere, no
// fixed point sizes" (10-accessibility.md Text scaling & visual accommodations).
// `.largeTitle` is the app name's register and it grows with the system text size;
// a fixed size would have stayed put while every line beneath it grew past it.
.font(.largeTitle.weight(.light))
.padding(.top, BoardMetrics.em(0.9, bodyPointSize: Self.pointSize))
Text(versionSummary)
.font(.callout)
@@ -90,11 +112,11 @@ struct WelcomeView: View {
Text("Folders and Markdown, on your terms.")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.top, 4)
.padding(.top, BoardMetrics.em(0.3, bodyPointSize: Self.pointSize))
Spacer(minLength: 24)
Spacer(minLength: BoardMetrics.em(1.85, bodyPointSize: Self.pointSize))
VStack(spacing: 8) {
VStack(spacing: BoardMetrics.em(0.6, bodyPointSize: Self.pointSize)) {
// The menu-bar twin of this button is File New Board (N) same action, and
// deliberately the same words, because a button and a menu item that differ read as
// two features.
@@ -216,7 +238,7 @@ struct WelcomeView: View {
}
.controlSize(.small)
}
.padding(16)
.padding(BoardMetrics.em(1.25, bodyPointSize: Self.pointSize))
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -249,7 +271,7 @@ private struct WelcomeActionButton: View {
Button(action: action) {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
.padding(.vertical, BoardMetrics.em(0.15, bodyPointSize: WelcomeView.pointSize))
}
.buttonStyle(.bordered)
.controlSize(.large)
@@ -271,9 +293,13 @@ private struct RecentBoardRow: View {
// lenient-fallback shape every other icon site in the app uses (`ItemSymbol`,
// `Palette`), applied here to the registry's cached string instead of a live snapshot.
Image(systemName: iconName)
.font(.system(size: 22))
// Relative, like every other size in this window (10-accessibility.md's
// full-relative-scaling rule): `.title` is the register a 22pt glyph occupied at the
// standard text size, and the well around it is derived from the body font so the
// glyph never outgrows it.
.font(.title)
.foregroundStyle(iconTint)
.frame(width: 34, height: 34)
.frame(width: iconWell, height: iconWell)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
@@ -292,13 +318,19 @@ private struct RecentBoardRow: View {
Spacer(minLength: 0)
}
.padding(.vertical, 4)
.padding(.vertical, BoardMetrics.em(0.3, bodyPointSize: WelcomeView.pointSize))
// Dimmed when the board cannot be reached the row stays, with Forget, rather than
// disappearing (02 § Graceful orphaning).
.opacity(row.isAvailable ? 1 : 0.55)
.accessibilityElement(children: .combine)
}
/// The square the row's glyph sits in font-derived so the icon column stays proportionate to
/// the three lines of text beside it at every system text size.
private var iconWell: CGFloat {
BoardMetrics.em(2.6, bodyPointSize: BoardMetrics.bodyPointSize)
}
/// `row.icon`'s symbol if it names one this system can draw, the board default otherwise
/// `ItemSymbol.name(_:fallback:)`'s rule, restated for a plain cached string rather than a
/// `FieldValue`: a record carries no `FieldValue`, so `missing`/`malformed`/`unrecognized`
@@ -365,7 +397,10 @@ struct SettingsView: View {
}
}
.formStyle(.grouped)
.frame(width: 420)
// Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip its one
// toggle's footer sentence at a large system text size with no way to resize out of it
// (10-accessibility.md Text scaling).
.frame(width: BoardMetrics.em(32.3, bodyPointSize: BoardMetrics.bodyPointSize))
.fixedSize()
}
}