The board speaks when files change under the user, per DESIGN/10 § Live board announcements. BoardDiff is the pure snapshot summarizer (identity sets for cards/lanes added/edited/moved/deleted — ids, not tallies, so pro-m1's semantic commit engine can build on it; edited = rendered content only, moved beats edited, implied events don't steal the subject). BoardAnnouncer is the decision seam: focusOutcome computes the vanishing-focus sentence and the walk-up-then-sideways recovery (next lane by order, else previous, board container only when none remain, never the trash); speech(for:) is the one-sentence precedence ladder — raised condition > bracket completion > cleared condition > vanished focus > digest — foreign-only for the last two rungs, so app-mediated echoes stay silent. BoardStore.land assembles ReloadFacts and posts exactly one sentence per reload through the injectable announce outlet (AccessibilityAnnouncer, medium priority, never interrupting). Selection recovery layers on top of ItemReferenceSet re-resolution — survivors veto, the emptied selection lands on the vanished item's lane and re-arms ⌘N's active-lane memory. performWholesale(announcing:) arms a completion phrase consumed by the closing reload — nil on every base bracket today; pro-m1 fills git phrasings. Locks raised outside the reload path (vanished root, unwritable location) announce through the same ladder, and the banner strip is a labeled "Board status" container whose row labels are the announced sentences (AccessibilityPhrases.bannerLabel — one string for eye and ear). Announcements classify at reload granularity (WatchOrigin) as a deliberate interim: DESIGN/02's EchoLedger (per-file classification, the announcer's specified input, git-free) was scheduled with the auto-committer that the edition split moved to pro-m1 — filed on the Redesign board for a ruling. 1533 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
292 lines
12 KiB
Swift
292 lines
12 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
|
|
|
|
@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: 6) {
|
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
.imageScale(.small)
|
|
Text(isExpanded ? "Show fewer" : "+\(hiddenCount) more")
|
|
Spacer(minLength: 0)
|
|
}
|
|
.font(.callout)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.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: 8) {
|
|
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, 12)
|
|
.padding(.vertical, 8)
|
|
.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 survive as custom actions of the combined element rather than as separate stops.
|
|
//
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var trailingControls: some View {
|
|
if case let .inProgress(operation) = row, let cancel = operation.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("Cancel", action: cancel)
|
|
.buttonStyle(.link)
|
|
.font(.callout)
|
|
}
|
|
|
|
if let dismissID = row.dismissID {
|
|
Button {
|
|
onDismiss(dismissID)
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.imageScale(.small)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Dismiss")
|
|
.help("Dismiss")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|