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