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:
@@ -103,6 +103,9 @@ struct BoardView: View {
|
||||
/// from the environment here and handed to `Motion`, which owns what "reduced" means for each.
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
||||
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
||||
/// must go back to meaning create/rename rather than nothing at all.
|
||||
@@ -110,7 +113,13 @@ struct BoardView: View {
|
||||
|
||||
/// The inter-lane gap, and the strip's outer margin — one number, because the standard-width
|
||||
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
|
||||
private let spacing: CGFloat = 12
|
||||
///
|
||||
/// **Font-derived** (`BoardMetrics.stripGap`), which is what keeps the no-horizontal-scroll
|
||||
/// invariant honest under 10-accessibility.md's full-relative-scaling rule: a larger system text
|
||||
/// size widens the gap and therefore *narrows* every lane, since the window's width still divides
|
||||
/// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the
|
||||
/// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility).
|
||||
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: BoardMetrics.bodyPointSize) }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
@@ -169,11 +178,20 @@ struct BoardView: View {
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
|
||||
// the strip is the window's content, not a control, and a rectangle around the whole board
|
||||
// would read as an error state.
|
||||
// **The board is one tab stop** (10-accessibility.md ▸ Full Keyboard Access: "the board is
|
||||
// one tab stop with arrow-key navigation within"), and this is it: one focusable strip,
|
||||
// whose interior movement is the arrow grammar below rather than a focus stop per card.
|
||||
// The controls *around* the cards — each lane's new-card button, the popovers, the toolbar —
|
||||
// are ordinary `Button`s and stay Tab-reachable in their own right, which is the other half
|
||||
// of the same sentence ("every control … is Tab-reachable").
|
||||
//
|
||||
// The focus *ring* is normally off: the strip is the window's content, not a control, and a
|
||||
// rectangle around the whole board would read as an error state. **Under Full Keyboard
|
||||
// Access it comes back**, because that reasoning inverts completely there — FKA's premise is
|
||||
// that the user can see where Tab landed, and an invisible tab stop is not one
|
||||
// (`Accommodations.isFullKeyboardAccessEnabled`).
|
||||
.focusable()
|
||||
.focusEffectDisabled()
|
||||
.focusEffectDisabled(!Accommodations.isFullKeyboardAccessEnabled)
|
||||
.focused($isBoardFocused)
|
||||
.onAppear {
|
||||
isBoardFocused = true
|
||||
@@ -270,7 +288,7 @@ struct BoardView: View {
|
||||
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
||||
// will occupy — its units measured against *this* strip's standard, which is
|
||||
// what makes the drop land precisely where the shadow shows.
|
||||
DragShadow()
|
||||
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize))
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
@@ -335,7 +353,15 @@ struct BoardView: View {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
.frame(width: rect.width, height: rect.height)
|
||||
.overlay(Rectangle().strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 1))
|
||||
// Increase Contrast takes the band's edge to full strength and a point heavier: the
|
||||
// fill is a 12%-alpha wash by design, so the border is the only thing that says where
|
||||
// the sweep actually reaches (`Accommodations`).
|
||||
.overlay(
|
||||
Rectangle().strokeBorder(
|
||||
Color.accentColor.opacity(Accommodations.accentOpacity(0.5, contrast: contrast)),
|
||||
lineWidth: Accommodations.borderWidth(1, contrast: contrast)
|
||||
)
|
||||
)
|
||||
.offset(x: rect.minX, y: rect.minY)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
@@ -396,9 +422,12 @@ struct BoardView: View {
|
||||
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
|
||||
ZStack(alignment: .topLeading) {
|
||||
if resizing {
|
||||
DragShadow(dashed: false)
|
||||
.frame(width: slotWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
DragShadow(
|
||||
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize),
|
||||
dashed: false
|
||||
)
|
||||
.frame(width: slotWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
// Interior columns follow the SNAPPED unit count while this lane is being resized — a
|
||||
// column count is integral, so it tracks k (which ticks and animates), not the live
|
||||
|
||||
Reference in New Issue
Block a user