Files
lanework/Kanban/UI/Accommodations.swift
rzen 51cf994cb9 Lane selection sheds its slab — a hairline edge with a soft halo, intensity inverse to area
The whole-lane wash-and-solid-ring scaled its emphasis with the lane's
area: what read as a ring on a card read as a slab on a column. Resettled
as the system focus ring's vocabulary at selection strength — a hairline
accent edge with a soft accent glow bleeding outward, riding the stroke
shape rather than the translucent plate (a shadow behind it would bleed
through as murk), and no wash at all: nothing tints the cards' backdrop,
and lane colour stays the accent band's alone. Under Increase Contrast the
halo yields to the solid full-alpha ring — "strengthens borders" means
crisper, and a blur is the one thing a border cannot become — so the
setting sees exactly the indicator it always has; cards and trash rows are
card-scale and keep the solid ring at every contrast. One selection
vocabulary, pinned by Accommodations.drawsSelectionHalo and its test.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:36 -04:00

220 lines
12 KiB
Swift

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
}
/// Whether a selected lane draws its **halo** — the soft accent glow bleeding outward from the
/// plate's hairline edge (`LaneView.selectionStroke`), the system focus ring's vocabulary at
/// selection strength.
///
/// Under Increase Contrast the answer is no, and that is the setting's own logic rather than a
/// retreat: "strengthens borders and the selection indicator" means *crisper*, and a blur is
/// the one thing a border cannot become. The edge instead goes full-alpha and heavier
/// (`accentOpacity`, `borderWidth` at the solid ring's base), so the setting sees the same
/// unambiguous solid ring it always has — the halo is the resting treatment's softness, not
/// the indicator itself.
static func drawsSelectionHalo(contrast: ColorSchemeContrast) -> Bool {
contrast != .increased
}
/// 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 subjects on the board
/// are the transient search bar's `.bar` material and the backdrop's title-bar frost — the
/// "next material to arrive" this type was stated for, and it found the answer already written.
enum Underlay: Equatable {
/// `Material.bar` — the find-bar's own backdrop, translucent over the board beneath it.
case glass
/// `Material.thin` — the title-bar frost over a custom board backdrop
/// (`BoardView.boardBackground`). Deliberately not `.bar` — the find-bar sits over lanes
/// the board's own plates have already calmed, where the frost sits directly on an image
/// the author may well have chosen *for* its busyness — and deliberately not the heavier
/// notches either, tried and retired: `.ultraThick` read as a cloudy plate where a backdrop
/// should still show through, and `.regular` still veiled it more than the chrome needs.
/// The thin weight carries the chrome, and the dissolve below it (`BoardView.frostStrip`)
/// is what keeps the strip from reading as a bar.
case frost
/// The window's own background colour, opaque.
case solid
var style: AnyShapeStyle {
switch self {
case .glass: AnyShapeStyle(.bar)
case .frost: AnyShapeStyle(.thinMaterial)
case .solid: AnyShapeStyle(Color(nsColor: .windowBackgroundColor))
}
}
}
static func underlay(reduceTransparency: Bool) -> Underlay {
reduceTransparency ? .solid : .glass
}
/// The title-bar frost's own reading of the same rule — heavier glass, identical accommodation:
/// under Reduce Transparency both underlays take the one solid.
static func frost(reduceTransparency: Bool) -> Underlay {
reduceTransparency ? .solid : .frost
}
/// A **translucent wash** — a tint laid over whatever happens to be behind it, and the shape
/// every non-material translucency on the board takes: every lane's plate, 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)
}
}
}
/// Every lane's resting plate (03-board-ui.md § Styling ▸ Capabilities — the standard chrome's
/// middle step, the pathfinder's lane surface carried over). Translucent so a board-chosen
/// colour shows through it, which is what keeps `BoardTextInk`'s premise true for the header
/// text sitting on it.
static func lanePlateWash(reduceTransparency: Bool) -> Wash {
reduceTransparency ? .opaque : .translucent(opacity: 0.35)
}
/// The trash column's plate — the same figure as an ordinary lane's, kept as its own knob
/// because the trash answers to its own rendering spec (03-board-ui.md § Trash ▸ Rendering),
/// and the quietest of its treatments: the hatched header above 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
}
}