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(BoardLoadFailure(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) }