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:
@@ -0,0 +1,181 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// The system's **visual accommodations**, as one named surface — `Motion`'s sibling
|
||||
/// (10-accessibility.md ▸ Text scaling & visual accommodations). Motion owns Reduce Motion; this owns
|
||||
/// the other two settings the design commits to, plus the one that decides whether a focus ring is
|
||||
/// furniture or a lifeline:
|
||||
///
|
||||
/// - **Increase Contrast** — "strengthens borders and the selection indicator";
|
||||
/// - **Reduce Transparency** — "glass underlays go solid, wherever they appear";
|
||||
/// - **Full Keyboard Access** — "the board is one tab stop with arrow-key navigation within".
|
||||
///
|
||||
/// The rule this type exists to enforce is `Motion`'s, transplanted: **no call site anywhere decides
|
||||
/// for itself what an accommodation means.** A view that draws a border asks for a border width by
|
||||
/// meaning and passes in what the environment reports; what "increased" does to that width is
|
||||
/// decided once, here, so a new bordered surface inherits the answer instead of inventing one.
|
||||
///
|
||||
/// ### Why the decisions are pure functions of an environment value
|
||||
///
|
||||
/// Same reason `Motion.reloadAnimates` is: so a test can hold them still. Nothing below renders, and
|
||||
/// the claims 10-accessibility.md actually makes — a heavier ring under Increase Contrast, a solid
|
||||
/// underlay under Reduce Transparency — are assertable only if the decision is separable from the
|
||||
/// drawing. The two `AnyShapeStyle`-producing families therefore go through small `Equatable` enums
|
||||
/// (`Underlay`, `Wash`) exactly as the transitions go through `Motion.Appearance`, because
|
||||
/// `AnyShapeStyle` is opaque and a claim about it would be untestable.
|
||||
///
|
||||
/// ### Where the values come from
|
||||
///
|
||||
/// Views read `@Environment(\.colorSchemeContrast)` and `@Environment(\.accessibilityReduceTransparency)`
|
||||
/// and pass them in. Code with no environment to read asks AppKit the same questions
|
||||
/// (`prefersIncreasedContrast`, `prefersReducedTransparency`) — `Motion.prefersReducedMotion`'s
|
||||
/// pattern, for its reason.
|
||||
enum Accommodations {
|
||||
|
||||
// MARK: - Increase Contrast
|
||||
|
||||
/// A stroke's width: `base` normally, **one point heavier** when the user has asked for stronger
|
||||
/// borders (10-accessibility.md: "Increase Contrast strengthens borders and the selection
|
||||
/// indicator").
|
||||
///
|
||||
/// One point rather than a multiplier, deliberately. The board's strokes span 1pt (a well's
|
||||
/// separator hairline) to 3pt (the template chooser's selection frame), and a factor that made
|
||||
/// the hairline legible would turn the chooser's frame into a slab. A flat point is what the
|
||||
/// system's own controls do under the setting, and it is monotone: a heavier stroke stays
|
||||
/// heavier than a lighter one, so the visual hierarchy the widths encode survives the setting.
|
||||
///
|
||||
/// It is *not* scaled by the text size, and that is a ruling rather than an oversight: a border
|
||||
/// is a hairline against a background, not a glyph — AppKit's own controls keep their stroke
|
||||
/// weights across text sizes, and a 3pt selection ring at a large text size would read as a
|
||||
/// fill.
|
||||
static func borderWidth(_ base: CGFloat, contrast: ColorSchemeContrast) -> CGFloat {
|
||||
contrast == .increased ? base + 1 : base
|
||||
}
|
||||
|
||||
/// Whether a plate that normally floats on its fill alone draws an **outline** at all.
|
||||
///
|
||||
/// This is the other half of "strengthens borders", and the half that is easy to miss: a card
|
||||
/// face and a lane plate carry no resting border — they are a fill against the board background,
|
||||
/// which is exactly the distinction Increase Contrast exists to rescue for a user who cannot see
|
||||
/// it. So under the setting they gain a hairline in the separator colour, and the selection ring
|
||||
/// above stays what it always was: the *accent*-coloured one, still unambiguous against it.
|
||||
///
|
||||
/// Chrome, never information: nothing about the board's meaning changes, so nothing has to be
|
||||
/// said differently to VoiceOver when this flips.
|
||||
static func drawsRestingBorder(contrast: ColorSchemeContrast) -> Bool {
|
||||
contrast == .increased
|
||||
}
|
||||
|
||||
/// A decorative accent drawn at reduced alpha — the marquee band's border, the drag shadow's
|
||||
/// dashes, the new-card editor's well — taken to **full strength** under Increase Contrast.
|
||||
///
|
||||
/// Alpha is the other way a border can be weak, and a width bump alone would leave a 55%-alpha
|
||||
/// dashed outline just as hard to see two points wider.
|
||||
static func accentOpacity(_ base: Double, contrast: ColorSchemeContrast) -> Double {
|
||||
contrast == .increased ? 1 : base
|
||||
}
|
||||
|
||||
/// Increase Contrast, asked of AppKit rather than of the SwiftUI environment — for callers built
|
||||
/// outside a rendered hierarchy, where the environment's accessibility values are not reliably
|
||||
/// populated (`Motion.prefersReducedMotion`'s constituency).
|
||||
@MainActor
|
||||
static var prefersIncreasedContrast: Bool {
|
||||
NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast
|
||||
}
|
||||
|
||||
// MARK: - Reduce Transparency
|
||||
|
||||
/// What a **glass underlay** is made of — a real material, or the solid the setting replaces it
|
||||
/// with (10-accessibility.md: "Reduce Transparency: glass underlays go solid, wherever they
|
||||
/// appear").
|
||||
///
|
||||
/// The design's own example (the card face carousel's page dots) died with the carousel
|
||||
/// (03-board-ui.md § Card face's no-carousel resettlement), so the rule's one surviving subject
|
||||
/// on the board is the transient search bar's `.bar` material. It is stated as a type anyway
|
||||
/// rather than inlined at that one call site, because "wherever they appear" is a standing rule
|
||||
/// and the next material to arrive should find the answer already written.
|
||||
enum Underlay: Equatable {
|
||||
/// `Material.bar` — the find-bar's own backdrop, translucent over the board beneath it.
|
||||
case glass
|
||||
/// The window's own background colour, opaque.
|
||||
case solid
|
||||
|
||||
var style: AnyShapeStyle {
|
||||
switch self {
|
||||
case .glass: AnyShapeStyle(.bar)
|
||||
case .solid: AnyShapeStyle(Color(nsColor: .windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func underlay(reduceTransparency: Bool) -> Underlay {
|
||||
reduceTransparency ? .solid : .glass
|
||||
}
|
||||
|
||||
/// A **translucent wash** — a tint laid over whatever happens to be behind it, and the shape
|
||||
/// every non-material translucency on the board takes: the trash column's plate and hatched
|
||||
/// header, and the drag shadow's fill.
|
||||
///
|
||||
/// These are not glass, and the distinction matters enough to keep two types: a material samples
|
||||
/// and blurs its backdrop, a wash simply composites at an alpha. But they fail the same way for
|
||||
/// the same user — the board's `background` is a colour the *user* chose (03-board-ui.md §
|
||||
/// Styling), so a 35%-alpha plate over a saturated board is exactly the "what is behind this"
|
||||
/// problem Reduce Transparency exists to remove. Under the setting each one takes the standard
|
||||
/// secondary background instead, which is opaque and appearance-aware.
|
||||
enum Wash: Equatable {
|
||||
/// `.quaternary` at `opacity`, over whatever is behind.
|
||||
case translucent(opacity: Double)
|
||||
/// The standard secondary background — opaque, so nothing shows through.
|
||||
case opaque
|
||||
|
||||
var style: AnyShapeStyle {
|
||||
switch self {
|
||||
case let .translucent(opacity): AnyShapeStyle(HierarchicalShapeStyle.quaternary.opacity(opacity))
|
||||
case .opaque: AnyShapeStyle(.background.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trash column's plate — the quietest of the three, since the header above it carries the
|
||||
/// column's identity.
|
||||
static func trashPlateWash(reduceTransparency: Bool) -> Wash {
|
||||
reduceTransparency ? .opaque : .translucent(opacity: 0.35)
|
||||
}
|
||||
|
||||
/// The trash column's hatched header. Heavier than the plate, because it is the whole of "you
|
||||
/// are looking at the trash" (03-board-ui.md § Trash ▸ Rendering).
|
||||
static func trashHeaderWash(reduceTransparency: Bool) -> Wash {
|
||||
reduceTransparency ? .opaque : .translucent(opacity: 0.5)
|
||||
}
|
||||
|
||||
/// A drop shadow's fill — the outline occupying an item's proposed landing spot (`DragShadow`).
|
||||
static func dragShadowWash(reduceTransparency: Bool) -> Wash {
|
||||
reduceTransparency ? .opaque : .translucent(opacity: 0.5)
|
||||
}
|
||||
|
||||
/// Reduce Transparency, asked of AppKit — `prefersIncreasedContrast`'s twin, same constituency.
|
||||
@MainActor
|
||||
static var prefersReducedTransparency: Bool {
|
||||
NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency
|
||||
}
|
||||
|
||||
// MARK: - Full Keyboard Access
|
||||
|
||||
/// Whether the system's **Full Keyboard Access** is on.
|
||||
///
|
||||
/// It has one caller and one purpose: the board strip suppresses its focus ring, because "the
|
||||
/// strip is the window's content, not a control, and a rectangle around the whole board would
|
||||
/// read as an error state" (`BoardView`) — and that reasoning inverts completely under FKA,
|
||||
/// where 10-accessibility.md makes the board **one tab stop** and a tab stop nobody can see is
|
||||
/// not one. So the ring comes back exactly when Tab can land on it.
|
||||
///
|
||||
/// There is no SwiftUI environment value for this and no change notification to observe, so it
|
||||
/// is read at body evaluation like any other system query here. That is honest for what it is: a
|
||||
/// setting a user turns on once (⌃F7, or System Settings ▸ Keyboard), not one that flips during
|
||||
/// a gesture — and a board window re-renders on nearly every interaction, so a flip is picked up
|
||||
/// almost immediately rather than never.
|
||||
@MainActor
|
||||
static var isFullKeyboardAccessEnabled: Bool {
|
||||
NSApp?.isFullKeyboardAccessEnabled ?? false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user