Files
lanework/Kanban/UI/Board/LaneResizeHandle.swift
T
rzen 8564814754 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
2026-07-29 08:48:25 -04:00

99 lines
4.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
/// The invisible 12pt grab strip overlaid at a lane's trailing edge that drives a
/// `LaneResizeSession` from a `DragGesture` (03-board-ui.md § Lane, right-edge drag-to-resize).
///
/// Overlaid so ~8pt hangs into the inter-lane gap and only ~4pt sits over the lane itself (clear of
/// the lane's own vertical scrollbar, which lives at that inner edge). **Every lane gets one, the
/// last included** — the rightmost lane's drag is the one that grows the window into free screen
/// space, which is the interaction's headline case.
///
/// It carries no drop target of its own, so it never participates in card/lane/file drops, and as an
/// overlay it hit-tests above the lane body's own gestures.
struct LaneResizeHandle: View {
let store: BoardStore
let session: LaneResizeSession
let laneID: ItemID
/// The lane's committed unit count — the k the session starts from.
let committedUnits: Int
/// The strip's standard (1×) width THIS render; captured as the frozen standard the instant the
/// drag begins.
let standard: CGFloat
let gap: CGFloat
/// How the session reaches the host window it resizes. A closure rather than a stored
/// `NSWindow?` because the window attaches asynchronously (`WindowAccessor`), and a value
/// captured in an early body evaluation would be `nil` for the window's whole life.
let window: @MainActor () -> NSWindow?
/// Guards `NSCursor` push/pop balance — a fast cursor can leave the strip mid-drag, and the drag
/// can end on or off it, so pushes and pops must be idempotent to never leave a stuck resize
/// cursor.
@State private var cursorPushed = false
/// The grab strip's width and its rightward shift — both font-derived, because the inter-lane
/// gap they are proportioned against is (`BoardMetrics.stripGap`, 10-accessibility.md's
/// full-relative-scaling rule). At the standard body size they are the 12pt and 8pt the strip
/// has always used: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
/// into the gap (clear of the lane's own scrollbar).
private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: BoardMetrics.bodyPointSize) }
private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: BoardMetrics.bodyPointSize) }
var body: some View {
Color.clear
.frame(width: handleWidth)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.offset(x: overhang)
.onHover { inside in
if inside { pushCursor() } else { popCursor() }
}
// `.global` (window-fixed) space, NOT the handle's own: a tick moves the handle with its
// lane, but the window's top-left is pinned (right-edge growth), so a window-fixed
// translation stays a faithful physical-cursor delta throughout the drag.
.gesture(
DragGesture(minimumDistance: 2, coordinateSpace: .global)
.onChanged { value in
if !session.isResizing(laneID) {
// m5-drag: a resize and a card/lane move must not run at once — they
// would both mutate the same strip layout. The board's drag state does
// not exist yet; when it does, this is where the `isDragging` guard goes.
pushCursor()
session.begin(laneID: laneID, units: committedUnits,
standard: standard, gap: gap, window: window())
}
session.update(translation: value.translation.width)
}
.onEnded { _ in
guard session.isResizing(laneID) else { return }
session.end { id, units in store.setLaneWidth(id, units: units) }
popCursor()
}
)
// **Pointer-only, and out of the tree** — "the header context menu's width stepper — and
// its keyboard face, the Increase/Decrease Lane Width menu items — is the accessible
// path; edge drag is enhancement only" (10-accessibility.md ▸ Moving without dragging).
// An invisible strip that can only be dragged is a stop with nothing behind it, and it
// would sit between two lane containers in the strip's traversal.
.accessibilityHidden(true)
}
private func pushCursor() {
guard !cursorPushed else { return }
NSCursor.resizeLeftRight.push()
cursorPushed = true
}
private func popCursor() {
guard cursorPushed else { return }
NSCursor.pop()
cursorPushed = false
}
}