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
+8 -1
View File
@@ -72,7 +72,14 @@ struct BoardWindowHost: View {
var body: some View {
content
.frame(minWidth: 640, minHeight: 400)
// Font-derived like everything else the board lays out (`BoardMetrics.windowMinimumSize`,
// 10-accessibility.md's full-relative-scaling rule): at a large system text size a
// 640×400 floor would be narrower than two lane headers, and "every lane is always on
// screen" would degrade into a strip of truncation.
.frame(
minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width,
minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height
)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
.task { await start() }
+120 -18
View File
@@ -76,6 +76,19 @@ struct TemplateChooserView: View {
@State private var selection: TemplateRow.ID?
/// Which tile holds the keyboard.
///
/// **The grid's Full Keyboard Access wiring** (10-accessibility.md Full Keyboard Access: "every
/// control template chooser is Tab-reachable"). Before this the tiles were bare
/// `onTapGesture`s: the chooser could be Tabbed as far as Cancel and Choose, but the *choice*
/// itself was pointer-only, so a keyboard user could only ever create the default template.
///
/// Focus and selection are deliberately the same thing here, unlike the style editor's grids
/// where "selection is never implied by focus" because a well writes to disk. A tile writes
/// nothing it names what Choose will act on so moving focus onto one *is* choosing it, which
/// is how every list and icon grid on the system behaves.
@FocusState private var focusedRow: TemplateRow.ID?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
/// The selected row, defaulting to the first which is Basic, the bundled tier's lowest order,
@@ -84,6 +97,24 @@ struct TemplateChooserView: View {
rows.first { $0.id == selection } ?? rows.first
}
// MARK: - Geometry
//
// Every figure the sheet lays out on, as a multiple of the body font `BoardMetrics`' rule
// applied to a window rather than to the board (10-accessibility.md Text scaling & visual
// accommodations). At the standard 13pt body they reproduce the numbers the chooser has always
// drawn: a 620 × 480 sheet, a 20pt inset, and tiles at least 170 points across.
@MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
@MainActor static var windowWidth: CGFloat { BoardMetrics.em(47.7, bodyPointSize: pointSize) }
@MainActor static var windowHeight: CGFloat { BoardMetrics.em(37, bodyPointSize: pointSize) }
@MainActor static var inset: CGFloat { BoardMetrics.em(1.55, bodyPointSize: pointSize) }
@MainActor static var tileMinimumWidth: CGFloat { BoardMetrics.em(13, bodyPointSize: pointSize) }
/// The width the grid actually gets the sheet minus its two insets. Used only by the arrow
/// handler, which needs a column count `.adaptive` never tells it.
@MainActor static var gridWidth: CGFloat { windowWidth - 2 * inset }
var body: some View {
VStack(spacing: 0) {
header
@@ -92,7 +123,11 @@ struct TemplateChooserView: View {
Divider()
footer
}
.frame(width: 620, height: 480)
// Font-derived, like every other frame in the app (10-accessibility.md Text scaling: "no
// fixed point sizes"). This is a *fixed* sheet the user cannot resize their way out of a
// clipped one so a 620×480 literal would put the header's two lines and the footer's blurb
// outside the window at a large system text size.
.frame(width: Self.windowWidth, height: Self.windowHeight)
.task {
rescan()
// Returning to the foreground is when a folder dropped into the revealed store becomes
@@ -135,27 +170,80 @@ struct TemplateChooserView: View {
}
.help("Reveal your templates folder in the Finder. Any board folder you put there becomes a template.")
}
.padding(20)
.padding(Self.inset)
}
// MARK: Grid
private var grid: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 20)], spacing: 20) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: Self.tileMinimumWidth), spacing: Self.inset)],
spacing: Self.inset
) {
ForEach(rows) { row in
TemplateCard(row: row, isSelected: row.id == selected?.id)
.onTapGesture { selection = row.id }
// The list convention welcome's recents use, for the same reason: a
// double click is how a chooser is answered without reaching for a button.
.onTapGesture(count: 2) { choose() }
.accessibilityAddTraits(row.id == selected?.id ? [.isSelected] : [])
// **Tab-reachable, and a button to the accessibility tree** the tile is
// the chooser's one act of choosing, so it has to be a control rather than a
// decorated rectangle that happens to answer clicks (10-accessibility.md
// Full Keyboard Access).
.focusable()
.focused($focusedRow, equals: row.id)
.accessibilityAddTraits(row.id == selected?.id ? [.isButton, .isSelected] : [.isButton])
// Space picks the focused tile the keyboard face of the single click above.
// Return is deliberately *not* handled here: it is the sheet's default action
// (Choose), and a tile that swallowed it would leave a keyboard user focused
// on their choice with no way to answer the chooser.
.onKeyPress(.space) {
selection = row.id
return .handled
}
}
}
.padding(20)
.padding(Self.inset)
// The arrows walk the tiles `StyleWellGrid`'s handler on the container, for its
// reason: a focused control does not consume arrow keys, so the press bubbles here and
// moving focus is all it does.
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
move(press.key)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(nsColor: .controlBackgroundColor))
// Focus *is* selection in this grid (see `focusedRow`), so the two are kept in step in one
// direction only: moving focus names the choice, and a pointer click that named a choice
// leaves focus alone rather than yanking it out from under the keyboard.
.onChange(of: focusedRow) { _, focused in
guard let focused else { return }
selection = focused
}
}
/// One step per press, clamped at the ends rather than wrapped `StyleWellGrid.move`'s rule,
/// for its reason: a grid whose last row is short would wrap into a hole.
///
/// The vertical step is the grid's own column count, which `.adaptive` decides at layout time
/// and no one here can read. It is recomputed from the same two numbers the `GridItem` was built
/// from, so / land a row away rather than an arbitrary distance.
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
guard !rows.isEmpty else { return .ignored }
let columns = max(1, Int(Self.gridWidth / (Self.tileMinimumWidth + Self.inset)))
let delta: Int
switch key {
case .leftArrow: delta = -1
case .rightArrow: delta = 1
case .upArrow: delta = -columns
case .downArrow: delta = columns
default: return .ignored
}
let current = rows.firstIndex { $0.id == (focusedRow ?? selected?.id) } ?? 0
let next = min(max(0, current + delta), rows.count - 1)
focusedRow = rows[next].id
return .handled
}
// MARK: Footer
@@ -187,7 +275,7 @@ struct TemplateChooserView: View {
// An unloadable row "can't be instantiated or previewed" (09), which is this line.
.disabled(selected?.template == nil)
}
.padding(20)
.padding(Self.inset)
}
// MARK: - Reveal
@@ -297,16 +385,25 @@ private struct TemplateCard: View {
let row: TemplateRow
let isSelected: Bool
/// Increase Contrast, for the tile's frame below 10-accessibility.md names both halves of it
/// ("strengthens borders and the selection indicator"), and this one shape is both
/// (`Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
private var cornerRadius: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }
var body: some View {
VStack(spacing: 8) {
VStack(spacing: BoardMetrics.em(0.6, bodyPointSize: pointSize)) {
content
.frame(height: 96)
.frame(height: BoardMetrics.em(7.4, bodyPointSize: pointSize))
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 8).fill(Color(nsColor: .textBackgroundColor)))
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(Color(nsColor: .textBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isSelected ? Color.accentColor : Color(nsColor: .separatorColor),
lineWidth: isSelected ? 3 : 1)
lineWidth: Accommodations.borderWidth(isSelected ? 3 : 1, contrast: contrast))
)
Label(row.name, systemImage: icon)
@@ -375,23 +472,28 @@ private struct TemplatePreview: View {
private static let laneLimit = 6
private static let cardLimit = 4
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
var body: some View {
HStack(alignment: .top, spacing: 5) {
// Every mark is a fraction of the body font, like the tile that holds it the preview is a
// miniature of the board, and the board scales (10-accessibility.md's full-relative-scaling
// rule). A fixed 5pt lane band inside a tile that grew would read as a hairline.
HStack(alignment: .top, spacing: BoardMetrics.em(0.4, bodyPointSize: pointSize)) {
ForEach(template.lanes.prefix(Self.laneLimit)) { lane in
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 2)
VStack(spacing: BoardMetrics.em(0.3, bodyPointSize: pointSize)) {
RoundedRectangle(cornerRadius: BoardMetrics.em(0.15, bodyPointSize: pointSize))
.fill(Self.tint(of: lane))
.frame(height: 5)
.frame(height: BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize))
ForEach(0 ..< min(lane.cards.count, Self.cardLimit), id: \.self) { _ in
RoundedRectangle(cornerRadius: 3)
RoundedRectangle(cornerRadius: BoardMetrics.em(0.25, bodyPointSize: pointSize))
.fill(.quaternary)
.frame(height: 12)
.frame(height: BoardMetrics.em(0.9, bodyPointSize: pointSize))
}
Spacer(minLength: 0)
}
}
}
.padding(10)
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.accessibilityHidden(true)
}
+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()
}
}