Files
lanework/Kanban/UI/Board/TrashLaneView.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

390 lines
22 KiB
Swift

import AppKit
import Observation
import SwiftUI
// MARK: - TrashLaneView
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash).
///
/// ### Ordinary cards, in a column that says where they are
///
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive and nothing
/// to draw differently: the column renders `store.snapshot.trash` — which the loader parsed with the
/// same card parse the lanes use and sorted by `order` like any lane's children — through the very
/// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place —
/// search, selection, rendering, styling, and clipboard all treat it exactly like any other card"
/// (03 § Trash), and one view is the only way to make *rendering* literally true rather than
/// approximately so: a trashed card keeps its icon, its icon tint, its left-edge accent stripe, its
/// attachments chip and its four-line title, because it is the same card and the same face.
///
/// Newest-first falls out of the ranks (every arrival mints one above the current top), so there is
/// no timestamp sort and no entry type here at all.
///
/// ### What makes it a column and not a lane
///
/// The chrome 03 asks for, and nothing beyond it — "trailing (rightmost) position when shown,
/// visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not
/// draggable, not resizable, excluded from lane reordering":
///
/// - it spans a **fixed one width unit** — no `width` frontmatter, no stepper, no resize handle, and
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
/// so there is no `Lane` value for any of those to act on);
/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
/// snapshot's lanes;
/// - it has **no new-card button**: nothing is created in the trash. There is deliberately no Empty
/// Trash button either — that command's home is File ▸ Empty Trash… (⇧⌘⌫), and 11-command-nexus.md
/// gives the column no pointer affordance of its own.
///
/// ### The drop it takes, and the drag it starts
///
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash) — the
/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
/// row**, because every arrival mints a rank above the current top.
///
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
/// ordinary `.cards` session in the `.trash` container (`CardFaceView.startTrashCardDrag`), and
/// `BoardDropContext.commitDrop` hands it to the same `moveCards`/`copyCards`/`receiveCards` every
/// board card uses. "Restoring is an ordinary move out … there is no restore-specific machinery and
/// no Put Back" (03 § Trash).
///
/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) —
/// and say so twice: the delegate clears the file highlight over the column, and the face's hover
/// treatment is board-only (`CardFaceRole`).
///
/// ### No editing in the trash
///
/// "No editing in the trash: trash cards don't open — double-click stops at selection; move it out
/// first." That is the face's `.trash` role: no double-tap recogniser, no rename editor, no Style…
/// rows — absences rather than a pile of `disabled` modifiers.
///
/// ### Accessibility
///
/// "When shown, it is the last container, labeled as Trash with its count. Its cards are ordinary
/// card elements" (10-accessibility.md, resettled 2026-07-28). The container name and value are set
/// here; the elements inside are ordinary card faces because they *are* ordinary card faces. The full
/// element tree — labels, values, traits, actions — is the accessibility milestone's.
struct TrashLaneView: View {
let store: BoardStore
/// The window's purge-alert host, threaded down rather than read from the focus system: a
/// context menu's content is built in its own host, where a `@FocusedValue` is not reliably the
/// board window's, and the row's Delete must raise the *same* alert the menu bar's does.
let confirmations: TrashConfirmations
/// The board window's drop machinery — a card's drag is an ordinary card session in the
/// **trash** container (`DragSession`, 04-interactions.md ▸ The trash).
let drops: BoardDropContext
/// The strip's rubber band. The column's empty space is its third surface, in the **trash**
/// container — "the rubber band stays on the side it started on" (04-interactions.md ▸ The
/// trash) — and every card face registers its frame into the same registry.
let marquee: MarqueeControl
/// Reduce Motion, for the card transition below — 10-accessibility.md names the trash
/// specifically ("and trash animations all get reduced variants").
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Reduce Transparency, for the column's two washes below — "glass underlays go solid, wherever
/// they appear" (10-accessibility.md). The trash plate and its hatched header are the board's
/// only translucent surfaces, and they sit over a background the *user* chose (03-board-ui.md §
/// Styling), which is exactly the case the setting exists for (`Accommodations.Wash`).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
/// The live body metric — this column's geometry is `LaneView`'s, derived from the same font
/// (`BoardMetrics`), because these are the same cards in a column that must read as their
/// sibling.
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
/// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of
/// the lanes rather than as a different kind of object.
private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }
/// Between the cards — `LaneView.cardSpacing`, because these are the same cards.
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
cards
}
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Accommodations.trashPlateWash(reduceTransparency: reduceTransparency).style)
)
// **The delete gesture's drop target**, over the whole column — the header included, since
// the ruling is "dropping a live card on the shown trash", not on one of its rows. It
// declares every type the board's other targets do, because single-target dispatch hands the
// deepest region whatever session is in flight and a narrower target would strand the rest
// (`TrashDropDelegate`).
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
// "The last container, labeled as Trash with its count" (10-accessibility.md ▸ Trash lane).
// `.contain` rather than `.combine`: the cards inside are ordinary card elements and must
// stay individually reachable — combining them would collapse the container the design asks
// VoiceOver to enter.
.accessibilityElement(children: .contain)
.accessibilityLabel(AccessibilityPhrases.trashLabel)
// The **rendered** count, like a lane's: the shown trash's cards participate in the filter,
// so a query narrows the spoken count exactly as it narrows the badge and the column itself.
.accessibilityValue(AccessibilityPhrases.trashValue(cards: renderedCards.count))
}
/// The cards the column shows.
///
/// **Shown, the trash's cards "participate in the filter exactly like any other card"**
/// (03-board-ui.md § Trash — "the point of the pivot"), so the search predicate narrows this
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
/// free because it reads this same value. Hidden, the column renders nothing and registers
/// nothing, so "hidden trash is invisible to search" needs no code at all.
///
/// **A card being dragged out renders here anyway**, unlike a lane's: the source stays visible in
/// the trash while the session is in flight, dimmed by the face's own treatment, because a
/// restore is not a removal until the write lands.
private var renderedCards: [Card] {
Self.rendered(store.snapshot.trash, filter: store.searchFilter)
}
/// `renderedCards` as a pure function of its two inputs — `LaneView.rendered`'s trash-side twin,
/// split out for the same reason: so the rule can be pinned without a view
/// (`SearchFilterTests`). The column, its count badge and its marquee registration all read the
/// property, which reads this.
///
/// Two of the lane's four inputs are absent, and each absence is a ruling: **no rename
/// exemption**, because nothing renames in the trash (04 ▸ The trash), and **no drag hiding**,
/// because a card dragged *out* of the trash stays visible in it until the write lands.
nonisolated static func rendered(_ trash: [Card], filter: SearchFilter) -> [Card] {
trash.filter { filter.matches($0) }
}
// MARK: - The delete gesture's landing
/// Where the delete gesture's shadow run opens in this column — always the topmost row — or `nil`
/// when no proposal names the trash, which is every other moment of the app's life.
private var proposal: Int? {
drops.session.trashProposal(onBoardRooted: store.rootURL)
}
/// The slots the column lays out: the cards, with the delete gesture's shadow run opened at the
/// top.
///
/// The run stands until the echo reload brings the real cards — the committed-overlay hold keeps
/// the arrangement the release proposed on screen for that round trip, exactly as every other
/// container's does (`CommittedHold`).
private var slots: [TrashSlot] {
var result = renderedCards.map(TrashSlot.card)
guard let proposal else { return result }
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
return result
}
// MARK: - Header
/// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge
/// (03-board-ui.md § Trash ▸ Rendering).
///
/// The hatching is what makes the column read as *not a lane* at a glance — the design asks for
/// "visually distinct", and a lane's header is the surface this must not be mistaken for. Now
/// that the cards inside wear their ordinary faces, this header is the *whole* of "you are
/// looking at the trash", which is why it keeps its full treatment rather than softening.
/// **State is never colour-alone** (10-accessibility.md): the header is hatched *plus* labeled.
///
/// It carries no gesture at all: no selection (the column "is never selectable as a lane"), no
/// reorder drag, no context menu.
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
Image(systemName: "trash")
.foregroundStyle(.secondary)
.imageScale(.medium)
Text("Trash")
.font(.headline)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
countBadge
Spacer(minLength: 0)
}
.padding(.horizontal, BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: pointSize))
.padding(.vertical, BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: pointSize))
.background {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
.fill(Accommodations.trashHeaderWash(reduceTransparency: reduceTransparency).style)
.overlay {
// The hatch pitch scales with the text (`BoardMetrics.trashHatchSpacing`): this
// is the trash's **non-colour** distinction, and a fixed 7pt pitch behind a
// headline twice its usual size reads as a texture rather than as hatching.
DiagonalHatch(spacing: BoardMetrics.trashHatchSpacing(bodyPointSize: pointSize))
.stroke(.quaternary, lineWidth: 1)
.clipShape(UnevenRoundedRectangle(
topLeadingRadius: cornerRadius,
topTrailingRadius: cornerRadius
))
}
}
// The container carries the label and the count (see `body`), so the header itself is
// decoration for VoiceOver rather than a second element saying the same thing.
.accessibilityHidden(true)
}
/// The card count — the same collection the body renders, so the badge cannot disagree with
/// what is on screen (`LaneView.countBadge`'s rule).
private var countBadge: some View {
Text("\(renderedCards.count)")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
.padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
.background(Capsule().fill(.quaternary))
}
// MARK: - Cards
/// The cards, scrollable, with the navigation head kept in view.
///
/// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar), watching the head rather
/// than the whole selection so exactly one column responds to any one arrow — `LaneView`'s rule,
/// on the trash side.
private var cards: some View {
ScrollViewReader { proxy in
scrollableCards
.onChange(of: store.transient.selectionHead) { _, head in
guard let head, renderedCards.contains(where: { $0.id == head }) else { return }
proxy.scrollTo(TrashSlot.identity(of: head))
}
}
}
private var scrollableCards: some View {
ScrollView(.vertical) {
// **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is
// one width unit, so its masonry is a single column — but it is the *same* layout the
// lanes use, which is what makes the drag's make-room reflow read as positional slides
// here exactly as it does there (DRAG-REORDER.md § The card masonry).
//
// Not lazy, for `LaneView`'s reason squared: every face must keep its drawn frame
// registered in `MarqueeTargetRegistry` — the rubber band sweeps those frames and the
// arrows navigate by them (`NavigationMath`) — and a lazy stack only builds the rows it
// has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
// because a trash is small: it holds one board's deletions, and Empty Trash… exists.
MasonryLayout(columns: 1, spacing: cardSpacing) {
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
Group {
switch slot {
case let .card(card):
CardFaceView(
store: store,
card: card,
role: .trash(confirmations: confirmations),
marquee: marquee,
drops: drops
)
case .shadow:
// The delete gesture's shadow, holding the topmost row open
// (04-interactions.md ▸ The trash). At the nominal card height: the cards
// being proposed have no face here yet to be measured.
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.frame(maxWidth: .infinity)
.frame(height: LaneDropRegistry.nominalCardHeight)
}
}
// A slot is a card, so it arrives and leaves in the card's dialect — a delete
// files one in, a restore or a purge takes one out, and both halves of that pair
// should read alike from either side of the strip. The transaction is the
// reload's, like the lanes' (`Motion.reloadAnimates`).
.transition(Motion.cardTransition(reduced: reduceMotion))
// `order`-keyed traversal, `LaneView`'s rule on the trash side. The column is
// one masonry column, so geometry and `order` agree here and the priority is
// belt over braces — written anyway because the *reason* it agrees is the
// column's fixed one width unit, which is a layout fact rather than a
// traversal guarantee, and a two-unit trash would silently read column-major.
.accessibilitySortPriority(Double(slots.count - index))
// The scroll target — `LaneView`'s rule, and outermost for its reason.
.id(slot.id)
}
}
// The reflow that opens the topmost row for the shadow, keyed on **the drop proposal and
// nothing else** (03-board-ui.md § Motion's narrow keys) — the trash column's own copy of
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
// below reach the column's full height rather than stopping where the last card ends —
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
// reason: a `ScrollView` proposes its content only the height that content asks for, so a
// view sized to fit its cards leaves the blank space beneath them un-hit-testable. "The
// column's gesture surface is full height" (04-interactions.md ▸ The trash, settled)
// needs that blank space to actually belong to the view the gesture below is on.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
.contentShape(Rectangle())
// The band's trash-side surface. It arms from the column's empty space, full height
// (above) included, so a drag can start from the blank area below the last card exactly
// as the board background allows on the live side — a drag begun on a face instead is that
// card's drag-out, and the begin guard makes that geometric rather than a matter of
// gesture priority (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(in: .trash))
}
}
}
// MARK: - What the column lays out
/// One slot of the trash column — a card, or one slot of the delete gesture's run.
///
/// `LaneSlot`'s smaller sibling — smaller by exactly one case, because nothing is ever created in the
/// trash so there is no placeholder to stand in for it — and keyed on the same principle: a slot's id
/// decides whether the echo reload reads as a *swap* or as a removal and an insertion.
private enum TrashSlot: Identifiable {
/// A card the snapshot's trash already holds.
case card(Card)
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
case shadow(index: Int)
var id: String {
switch self {
case let .card(card): Self.identity(of: card.id)
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
// than blinking (`LaneSlot`'s rule).
case let .shadow(index): "shadow:\(index)"
}
}
/// A card slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
}
// MARK: - The hatch
/// Diagonal hatching for the trash header — the "dimmed/hatched" treatment 03-board-ui.md asks for,
/// drawn rather than imaged so it takes whatever width the division gives the column.
///
/// The lines start a full header-height to the left of the leading edge so the first stroke reaches
/// the top-left corner instead of beginning partway across.
///
/// The pitch is the caller's (`BoardMetrics.trashHatchSpacing`), because it scales with the text:
/// the hatch is the trash's non-colour distinction (10-accessibility.md's never-colour-alone rule),
/// and a distinction that stops reading at a large text size is not one.
private struct DiagonalHatch: Shape {
var spacing: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
guard spacing > 0, rect.height > 0 else { return path }
var x = rect.minX - rect.height
while x < rect.maxX {
path.move(to: CGPoint(x: x, y: rect.maxY))
path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
x += spacing
}
return path
}
}