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
191 lines
10 KiB
Swift
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
|
|
}
|
|
}
|