Files
lanework/Kanban/UI/Card/CardSidebarSections.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

139 lines
7.1 KiB
Swift

import AppKit
import SwiftUI
// MARK: - Style
/// The sidebar's **Style** section: "the **embedded style editor** — background palette grid (with
/// the leading None well) and curated symbol grid, per 03-board-ui.md ▸ Styling ▸ Controls. Card
/// styling is discoverable here without a context menu; the same component appears in the board
/// popover and behind Style…" (05-card-window.md ▸ Style).
///
/// ### How this anchor differs from the other two — in one word, geometry
///
/// It hosts `StyleEditorView` itself, not a copy of it: the wells, the batch display, the arrow-key
/// grammar, the read-only disabling, the recents the None well deliberately does not record, and the
/// single `applyStyle` bracket every well's click rides are all the shared component's, identical
/// here. The only thing this anchor supplies beyond a target is a `StyleEditorLayout` — the sidebar
/// is narrower than the popover at every text size, so the grids fall in fewer columns and the
/// symbol grid draws whole instead of scrolling inside the sidebar's own scroll view.
///
/// ### The target is fixed, and that is the whole difference in behavior
///
/// The Style… popover *tracks*: its target is the selection at the moment the gesture named it,
/// re-resolved against every snapshot, dismissing when it empties (`StyleEditorSession`). This
/// section tracks nothing. Its target is this window's card, always — "the two embedded anchors need
/// none of this and get none: the card sidebar dismisses with its card's window, and the board
/// popover's target is the board itself" (`StyleEditorSession`'s own note). Which is why there is no
/// session here to resolve, no popover to dismiss, and no way for a board-side selection change to
/// re-aim the editor a card window is showing: the window's card is the target by construction, and
/// when that card stops existing the window goes with it (`CardWindowFate`).
struct CardStyleSection: View {
let store: BoardStore
let recents: StyleRecents
let cardID: ItemID
/// The live body metric, read here rather than passed in — `CardAttachmentsSection`'s pattern,
/// so every section in this sidebar derives its geometry the same way.
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// **What this section styles: this card, and nothing else.**
///
/// A one-line seam because it is a claim worth pinning rather than a computation worth reading:
/// every other anchor derives its target from something that moves (the selection, the board),
/// and the mistake this window could make is inheriting one of those. `.items` of exactly one id
/// also means the editor's own batch machinery is a no-op here — one subject, so the display is
/// never mixed and the write bracket holds one file.
nonisolated static func target(forCard id: ItemID) -> StyleTarget {
.items([id])
}
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Style")
StyleEditorView(
store: store,
recents: recents,
target: Self.target(forCard: cardID),
layout: .sidebar(
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
bodyPointSize: pointSize
)
)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Actions
/// The sidebar's **Actions** section, at the bottom of the stack (05-card-window.md ▸ Actions):
/// **Delete** — moves the card to the trash — and **Reveal in Finder** — the card's folder.
///
/// ### Delete writes; the window's dismissal is not its business
///
/// The button calls `BoardStore.deleteCard`, which is the ⌫ delete exactly (same write op, same
/// bracket, same stamps). It does not close this window: the card's move into `.trash/` rounds back through
/// the watcher and `CardWindowHost.cardWindowFate` takes the window down, which is the same path a
/// delete from the board — or from an agent — already takes. Dismissing from here as well would be a
/// second rule able to disagree with the first, and 05's own wording is a sequence rather than a
/// pair ("moves the card to the trash; the window then dismisses itself").
///
/// Recovery is the board's trash column, which is why this needs no confirmation: the card is still
/// there to drag out or cut and paste back (03-board-ui.md § Trash — there is no Put Back), and 03
/// reserves the alert for the purge that isn't recoverable.
///
/// ### Reveal is not edit-shaped
///
/// So it stays enabled under the read-only lock, where Delete does not — inspection is a read (04 ▸
/// The trash's posture, shared by the trash row's own Reveal). What it reveals comes from
/// `CardAttachments.revealURLs`, the same rule File ▸ Reveal in Finder's card-window scope answers
/// through: this button is that rule's card-folder branch by construction, since it is the *card's*
/// action rather than the attachment list's.
struct CardActionsSection: View {
let store: BoardStore
let cardID: ItemID
/// The card's own folder — `nil` only where the window has no board to build it from, which is a
/// window on its way out.
let cardFolder: URL?
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
private var revealURLs: [URL] {
CardAttachments.revealURLs(cardFolder: cardFolder, selectedURL: nil, isSectionFocused: false)
}
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Actions")
// Delete above Reveal, which is the order 05 lists them in. Destructive styling, per 05
// — the one control in this window that takes the card away. Disabled under the
// read-only lock like every other mutation (02-architecture.md's every-entry-point
// predicate); that is the attachments section's `isEditable`, read from the store
// directly because there is no handle to route it through here and nothing else in this
// section that would want one.
Button(role: .destructive) {
store.deleteCard(cardID)
} label: {
// The width is the *label's*, not the button's: a bordered button sizes to its label,
// so a frame around the button would centre a small pill in a wide row instead of
// filling it. Both rows do it, so the two are one column rather than two widths.
Text("Delete").frame(maxWidth: .infinity)
}
.tint(.red)
.disabled(store.isReadOnly)
Button {
NSWorkspace.shared.activateFileViewerSelecting(revealURLs)
} label: {
Text("Reveal in Finder").frame(maxWidth: .infinity)
}
.disabled(revealURLs.isEmpty)
}
.buttonStyle(.bordered)
.frame(maxWidth: .infinity, alignment: .leading)
}
}