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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user