Three ruled behavior changes (DESIGN/10, resolution session 2026-07-29): - The board-change digest covers the trash while View > Show Trash is on: BoardDiff.between gains includingTrash, keying its card index by ItemPath so foreign purges, restores, and Empty Trash join the digest; crossings of the trash boundary still read deleted/restored, never moved, on both sides of the toggle. BoardStore.land passes the store's own isTrashVisible - no new injection seam. - A vanished head with surviving co-selection is still named: naming and recovery are independent axes, so BoardAnnouncer's vanished-focus rung fires on all branches while the survivors-veto now gates only the recovery half (recovery implies vanished, no longer both-or-neither). - Banner-row buttons are literal FKA Tab stops: BannerRow.controls is the row's testable button inventory, BannerRowView renders from it with .focusable() on each button; the combined VoiceOver element stays unconditional - custom actions and Tab stops are independent surfaces. 19 tests added, 2 expectations updated to the rulings. 1607 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
329 lines
15 KiB
Swift
329 lines
15 KiB
Swift
import SwiftUI
|
|
|
|
/// The banner strip: one window's standing conditions, unread failures, and work in flight, as a
|
|
/// stack of rows (02-architecture.md § The banner surface).
|
|
///
|
|
/// ### Tones, not components
|
|
///
|
|
/// There is one row layout and three colorings. An error, a warning, and an info row differ in
|
|
/// symbol and tint and in nothing else — which is what lets the card window's remote-change
|
|
/// signpost (07-sync-collab.md) be "this same component in the info tone" rather than a second
|
|
/// thing that drifts. The per-kind affordances hang off the row's data, not off separate views: a
|
|
/// dismiss control appears exactly where `BannerRow.dismissID` is non-`nil`, a spinner and its
|
|
/// optional Cancel exactly where the row is `.inProgress`.
|
|
///
|
|
/// ### The collapse rule
|
|
///
|
|
/// "Beyond three rows the remainder collapse behind a '+N more' disclosure" — because the strip is
|
|
/// window furniture above the board, and a board that has gone badly wrong must not disappear
|
|
/// under its own error messages. Precedence ordering is what makes three the right number to show:
|
|
/// `BannerCenter.rows(...)` has already put the lock, the breakage, and the newest unread failure
|
|
/// at the top, so the collapsed remainder is always the least urgent tail.
|
|
///
|
|
/// **In-progress rows are exempt, and do not count toward the budget** (settled, 02): they are the
|
|
/// strip's only explanation for a bracket's write lock and for a close or quit deferring teardown,
|
|
/// and a copy row's Cancel has to stay reachable — "a spinner may never hide behind '+N more'".
|
|
/// They are safe to pin because there are few at once and each clears itself. So the strip is
|
|
/// always *every* pinned row, then at most three of the rest.
|
|
///
|
|
/// ### Deliberately self-contained
|
|
///
|
|
/// It takes rows and two callbacks; it reaches for no store and no environment. The board window
|
|
/// (m4) hosts it over the lane area, the card window (m6) hosts its own, and the re-homing rule —
|
|
/// a card window's condition moving to the board window's strip when it closes — is a question of
|
|
/// which `BannerCenter` a row came from, never of this view.
|
|
public struct BannerStripView: View {
|
|
|
|
private let rows: [BannerRow]
|
|
private let onDismiss: (UUID) -> Void
|
|
|
|
/// How many rows show before the disclosure takes over.
|
|
private static let collapseThreshold = 3
|
|
|
|
/// The strip's own rhythm, font-derived like everything else the app lays out
|
|
/// (`BoardMetrics`, 10-accessibility.md ▸ Text scaling: "no fixed point sizes"). It matters here
|
|
/// because a banner row *wraps* rather than truncating — the cause tail is the half that says
|
|
/// what to do — so a fixed inset around growing text crowds fast.
|
|
@MainActor static var rowHorizontalPadding: CGFloat { BoardMetrics.em(0.9, bodyPointSize: pointSize) }
|
|
@MainActor static var rowVerticalPadding: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }
|
|
@MainActor static var rowSpacing: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }
|
|
@MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
|
|
|
@State private var isExpanded = false
|
|
|
|
public init(rows: [BannerRow], onDismiss: @escaping (UUID) -> Void) {
|
|
self.rows = rows
|
|
self.onDismiss = onDismiss
|
|
}
|
|
|
|
public var body: some View {
|
|
if !rows.isEmpty {
|
|
VStack(spacing: 1) {
|
|
ForEach(pinnedRows) { row in
|
|
BannerRowView(row: row, onDismiss: onDismiss)
|
|
}
|
|
ForEach(visibleCollapsibleRows) { row in
|
|
BannerRowView(row: row, onDismiss: onDismiss)
|
|
}
|
|
if hiddenCount > 0 {
|
|
disclosure
|
|
}
|
|
}
|
|
.background(.quaternary)
|
|
// **The strip is an accessibility element** (10-accessibility.md ▸ Live board
|
|
// announcements, which makes the live-reload-resilience banner one by name). `.contain`
|
|
// rather than `.combine`: the rows stay individually focusable — each is already one
|
|
// element with its own tone-prefixed label, and a strip that fused three conditions into
|
|
// one utterance would bury the lock under the signposts. The group label is what a
|
|
// VoiceOver user hears on entering it, so "there are conditions here" arrives before the
|
|
// conditions do.
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel("Board status")
|
|
}
|
|
}
|
|
|
|
/// The rows that always show, in precedence order — in-progress rows, which `rows(...)` has
|
|
/// already placed at the head, so rendering them first preserves that order rather than
|
|
/// imposing a second one.
|
|
private var pinnedRows: [BannerRow] {
|
|
rows.filter(\.isPinned)
|
|
}
|
|
|
|
private var collapsibleRows: [BannerRow] {
|
|
rows.filter { !$0.isPinned }
|
|
}
|
|
|
|
/// Everything collapsible when expanded or short enough, the first three otherwise. `prefix`
|
|
/// and not a filter — precedence order is the whole point, so what shows is always a prefix of
|
|
/// the ordered list.
|
|
private var visibleCollapsibleRows: [BannerRow] {
|
|
isExpanded || collapsibleRows.count <= Self.collapseThreshold
|
|
? collapsibleRows
|
|
: Array(collapsibleRows.prefix(Self.collapseThreshold))
|
|
}
|
|
|
|
private var hiddenCount: Int {
|
|
max(0, collapsibleRows.count - Self.collapseThreshold)
|
|
}
|
|
|
|
private var disclosure: some View {
|
|
Button {
|
|
isExpanded.toggle()
|
|
} label: {
|
|
HStack(spacing: BannerStripView.rowSpacing) {
|
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
.imageScale(.small)
|
|
Text(isExpanded ? "Show fewer" : "+\(hiddenCount) more")
|
|
Spacer(minLength: 0)
|
|
}
|
|
.font(.callout)
|
|
.padding(.horizontal, BannerStripView.rowHorizontalPadding)
|
|
.padding(.vertical, BannerStripView.rowSpacing)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.background(.background.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: - One row
|
|
|
|
/// A single banner row. Everything kind-specific is a branch on the row's data; the layout is one
|
|
/// `HStack` for all five cases.
|
|
private struct BannerRowView: View {
|
|
|
|
let row: BannerRow
|
|
let onDismiss: (UUID) -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: BannerStripView.rowVerticalPadding) {
|
|
leading
|
|
|
|
Text(row.headline)
|
|
.font(.callout)
|
|
// Wrapping, never truncating: the cause tail ("— disk full") is the half that says
|
|
// what to do about it, and a strip that hid it would be decoration.
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
trailingControls
|
|
}
|
|
.padding(.horizontal, BannerStripView.rowHorizontalPadding)
|
|
.padding(.vertical, BannerStripView.rowVerticalPadding)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(row.tone.fill)
|
|
// One element per row, tone included: a VoiceOver user must hear *that* this is an error
|
|
// before hearing what the error is, and colour cannot carry that. The dismiss and Cancel
|
|
// buttons stay inside the combined element, where VoiceOver surfaces them as the row's
|
|
// custom actions — **and are Tab stops in their own right besides** (see `trailingControls`:
|
|
// 10-accessibility.md ▸ Full Keyboard Access, ruled 2026-07-29). The two are independent
|
|
// surfaces, so the combine is unconditional: nothing here is uncombined under FKA, because
|
|
// the focus loop does not need the AX tree's permission to include a button and a VoiceOver
|
|
// user would otherwise hear a different row depending on a keyboard setting.
|
|
//
|
|
// The label is composed by `AccessibilityPhrases` rather than spelled here because the two
|
|
// standing conditions are also *announced* on arrival and clearance (10-accessibility.md ▸
|
|
// Live board announcements, via `BoardAnnouncer`): the sentence a user hears when the lock
|
|
// appears and the sentence they read off the row afterwards are one string, or they are two
|
|
// descriptions of one condition waiting to disagree.
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel(Text(AccessibilityPhrases.bannerLabel(tone: row.tone, headline: row.headline)))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var leading: some View {
|
|
if case .inProgress = row {
|
|
// The spinner replaces the symbol rather than joining it: an in-progress row's state
|
|
// *is* "still going", and two glyphs saying so would be noise.
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else {
|
|
Image(systemName: row.tone.symbolName)
|
|
.foregroundStyle(row.tone.accent)
|
|
.imageScale(.medium)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
/// The row's buttons, rendered straight from `BannerRow.controls` — **the Tab loop the FKA
|
|
/// ruling asks for** (10-accessibility.md ▸ Full Keyboard Access, 2026-07-29: "'Every control'
|
|
/// is literal and includes banner-row buttons … FKA serves sighted keyboard-only users, to whom
|
|
/// VO custom actions are invisible, and Cancel on an in-progress operation is exactly the
|
|
/// control that cannot require a pointer").
|
|
///
|
|
/// Driven from the row's inventory rather than from a pair of `if`s over the same data, so what
|
|
/// a row's controls *are* is one testable fact (`BannerCenterTests`) instead of a view detail
|
|
/// that a headless suite cannot see.
|
|
@ViewBuilder
|
|
private var trailingControls: some View {
|
|
ForEach(row.controls) { control in
|
|
button(for: control)
|
|
// **A literal tab stop**, and the focus ring left on to show it — the style editor's
|
|
// wells' rule, for its reason: these are controls, not window furniture, so unlike
|
|
// the board strip (`BoardView`) there is nothing here to suppress. Stated on the
|
|
// button rather than left to the button style, because `.plain` and `.link` render
|
|
// as bare content and the combine above puts them inside another accessibility
|
|
// element: the focus item is asked for outright so neither can quietly cost the row
|
|
// its keyboard reach.
|
|
.focusable()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func button(for control: BannerRowControl) -> some View {
|
|
switch control {
|
|
case let .cancel(cancel):
|
|
// Cancel appears on safe copies only (02, settled): it means "remove the partial copy,
|
|
// nothing lost". Git brackets pass no closure and therefore get no button.
|
|
Button(control.label, action: cancel)
|
|
.buttonStyle(.link)
|
|
.font(.callout)
|
|
case let .dismiss(id):
|
|
Button {
|
|
onDismiss(id)
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.imageScale(.small)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(control.label)
|
|
.help(control.label)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Tone rendering
|
|
|
|
private extension BannerTone {
|
|
var symbolName: String {
|
|
switch self {
|
|
case .error: "exclamationmark.triangle.fill"
|
|
case .warning: "exclamationmark.circle.fill"
|
|
case .info: "info.circle.fill"
|
|
}
|
|
}
|
|
|
|
var accent: Color {
|
|
switch self {
|
|
case .error: .red
|
|
case .warning: .orange
|
|
case .info: .secondary
|
|
}
|
|
}
|
|
|
|
/// A wash, not a slab: the strip sits above the board and must read as furniture rather than as
|
|
/// a second window. Info is deliberately the calmest of the three — "visually calm, no error
|
|
/// colour" is the settled description of the signpost that shares this tone.
|
|
var fill: some ShapeStyle {
|
|
switch self {
|
|
case .error: AnyShapeStyle(Color.red.opacity(0.12))
|
|
case .warning: AnyShapeStyle(Color.orange.opacity(0.12))
|
|
case .info: AnyShapeStyle(.background.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
private func previewError(
|
|
_ operation: WriteOperation,
|
|
_ reason: BoardWriteError.Reason = .io(message: "the disk is full")
|
|
) -> BoardWriteError {
|
|
BoardWriteError(operation: operation, path: "/Users/x/Boards/Work/lane/index.md", reason: reason)
|
|
}
|
|
|
|
#Preview("Single error") {
|
|
BannerStripView(
|
|
rows: [.oneShot(OneShotBanner(error: previewError(.move(title: "Fix login"))))],
|
|
onDismiss: { _ in }
|
|
)
|
|
.frame(width: 520)
|
|
}
|
|
|
|
#Preview("Stacked tones") {
|
|
BannerStripView(
|
|
rows: [
|
|
.inProgress(InProgressOperation(label: "Pulling…")),
|
|
.readOnlyLock(.vanishedRoot),
|
|
.oneShot(OneShotBanner(error: previewError(.delete(title: "Ship the beta")))),
|
|
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
|
|
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
|
|
.signpost(InfoSignpost(message: "This card changed on the remote — your edits still win")),
|
|
],
|
|
onDismiss: { _ in }
|
|
)
|
|
.frame(width: 520)
|
|
}
|
|
|
|
/// A loss row on its own: warning tone, but — unlike the standing `historySuspended` condition
|
|
/// beside it in "Stacked tones" — dismissable, since a loss reports something that already
|
|
/// happened rather than an ongoing state (settled 2026-07-28, BannerCenter's `LossBanner`).
|
|
#Preview("Loss row") {
|
|
BannerStripView(
|
|
rows: [
|
|
.loss(LossBanner(message: BannerCenter.skippedFoldersMessage(count: 2))),
|
|
],
|
|
onDismiss: { _ in }
|
|
)
|
|
.frame(width: 520)
|
|
}
|
|
|
|
/// Seven rows, one of them pinned: the strip shows the spinner plus the first three of the rest,
|
|
/// and "+3 more" counts only what actually collapsed.
|
|
#Preview("Collapse") {
|
|
BannerStripView(
|
|
rows: [
|
|
.inProgress(InProgressOperation(label: "Importing 24 attachments…", cancel: {})),
|
|
.readOnlyLock(.bracketedReloadFailed),
|
|
.reloadBreakage(BoardLoadError(path: "todo/index.md", reason: .missingOrder)),
|
|
.oneShot(OneShotBanner(error: previewError(.move(title: "Fix login")))),
|
|
.oneShot(OneShotBanner(error: previewError(.style(title: "Design review")))),
|
|
.oneShot(OneShotBanner(error: previewError(.renumberChildren))),
|
|
.oneShot(OneShotBanner(error: previewError(.importAttachment(filename: "photo.png"),
|
|
.unreadable(message: "the source file could not be read")))),
|
|
],
|
|
onDismiss: { _ in }
|
|
)
|
|
.frame(width: 520)
|
|
}
|