Files
lanework/Kanban/UI/Accommodations.swift
T
rzen e1ffe71e5b A refused trash hover withdraws the trash's own promise — the fall-through holds only keepable ones
Gap #12 ruled into 04 ▸ the trash-drop bullet: the refusal fall-through's
honest reading (the column declines to be a target, it does not cancel
the held drag) covers only promises the refusal leaves keepable — lane
shadows keep drawing and an ⌥-copy lands where they are. A standing
proposal naming the trash itself is the promise the refusal just broke,
so it withdraws: the drag goes proposal-less over the column, release
with no proposal cancels in agreement with the empty picture, and
lifting ⌥ re-asks at the same address. Implementation filed on the
Implementation board.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-06 20:48:45 -04:00

191 lines
10 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
}
/// 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: 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
}
}