Files
lanework/Kanban/UI/Board/BoardInfoPopover.swift
T
rzen 798a8bac73 The tier axis comes out of the git stack — compose unconditional, postures mode-driven
HistoryStore.compose(boardRoot📒) returns non-optional and runs for
every session — the nil the gate produced was the only nil it ever had.
makeHistoryProvider is a one-axis decision: git-mode boards bind the git
provider, everything else native, in every tier; Session.tier stays
recorded, dormant. BoardGitSection shrinks to the four mode postures
(.absent and .proPointer die, BoardGitNote and the .git probe with them);
every board carries all three popover tabs (BoardInfoTab.available
retired); the titlebar branch shows on any git-mode board; the settings
sheet and card History section stop reading tier. InertGitTests is
repurposed as UntouchedGitTests — the file layer still never opens .git,
now load-bearing for mode-none boards. The accessibility audit reaches the
settings sheet at last: the fixture board hosts it in every tier, so the
free-fixture disabled-row test becomes an open-and-audit test.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 20:21:28 -04:00

504 lines
27 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
/// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the
/// widget in the window's titlebar that opens it.
///
/// **Tabbed since 2026-08-07 — the restructure is complete, all three tab sessions settled.** The
/// symbol/name header stays at the top; below it sit the tabs — **Info**, **Theme**, **Git** — each
/// the settings surface for one aspect of board configuration, each settled in its own dedicated
/// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color /
/// Pattern picker (the Background tab's original name, before the same session widened it past the
/// generated-only picker and folded manual styling back out to Style… ⌥⌘S); and `BoardGitTabView`,
/// which the pre-tab body's mode-aware git section rehomed into whole — postures, notes, and the
/// Board Settings… row, none of them re-ruled by the move.
///
/// **Tab membership is the git posture's**, and **selection resets to Info on every open** — both
/// the Git session's rulings. Since 12-editions.md ▸ PIVOT 2026-08-07 the first of those is a
/// structural rule with nothing left to exclude: every board carries all three tabs (03 ▸ Board
/// popover, the same-day pivot note) — see `BoardInfoTab`.
///
/// ### One home, deliberately
///
/// The popover has **no toolbar item** (§ Toolbar: "the window-title widget is its committed home
/// … and a second entry would muddy it"). It has exactly two ways in: the widget, and File ▸ Board
/// Info ⌘I, which is the same widget's popover reached from the keyboard (11-command-nexus.md's
/// class **C** — "the keyboard path is reachability … not bindings").
// MARK: - Presentation state
/// Whether **this window's** board popover is open.
///
/// Per window rather than per board or per app, and that is the point: ⌘I has to mean "the board in
/// front", so the flag travels with the window through the focus system (`FocusedValues.boardInfo`)
/// exactly as the store does. Two board windows each hold their own and can never toggle each
/// other's — which a flag on the store could not promise the day a board is allowed two windows.
///
/// It is also why this is not in `TransientBoardState`: everything there is *board*-scoped and
/// shared with the board's card windows, and a popover living in one window's titlebar is not that.
@MainActor
@Observable
final class BoardInfoPresentation {
var isPresented = false
/// ⌘I's whole behaviour and the widget's alike. **Toggling, not opening**: a shortcut aimed at a
/// disclosure that could only ever open would leave the popover with no keyboard way out.
func toggle() {
isPresented.toggle()
}
}
/// The focused board window's popover, beside `FocusedValues.boardStore` — see that key for why
/// board-window menu items reach their window this way rather than through the app model.
struct FocusedBoardInfoKey: FocusedValueKey {
typealias Value = BoardInfoPresentation
}
extension FocusedValues {
var boardInfo: BoardInfoPresentation? {
get { self[FocusedBoardInfoKey.self] }
set { self[FocusedBoardInfoKey.self] = newValue }
}
}
// MARK: - The window-title widget
/// The titlebar widget: the board's name — and, on a git-mode board, its branch — with a trailing
/// disclosure chevron, whose one job is this popover.
///
/// **The popover is anchored to the widget itself** — it hangs from the button rather than from the
/// window or the board — which is what makes the affordance and the surface read as one thing. A
/// `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a click
/// outside dismisses it), and because the content is SwiftUI either way; the AppKit half of this is
/// only the *placement* (`boardInfoTitlebarAccessory`).
///
/// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron
/// button to the full name/branch/chevron button): the title and branch strings sit inside the same
/// `Button`, so a click anywhere across the board's name — or its branch, when shown — opens the
/// popover exactly as a click on the chevron always has.
struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
/// The git state this board's **session** composed with — read once, at the moment the widget is
/// installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse never interrupts
/// an open session"). `git` is a reference type and `@Observable`, so add-git flipping the mode,
/// or a branch switch, redraws the widget without anything here being re-created.
///
/// It stopped being a tier signal at 12 ▸ PIVOT 2026-08-07 — every session composes a git state
/// now, whatever the tier — so a `nil` here means only "this caller had no session to hand over"
/// (previews, the accessory-installation tests), which reads as mode `none`.
let git: HistoryStore?
@Bindable var presentation: BoardInfoPresentation
/// The window's settings sheet, so the popover's Git tab can carry the **Board Settings…**
/// row that opens it (03-board-ui.md ▸ Board popover: "A Board Settings… row opens the sheet —
/// the popover's one setup affordance"). `nil` where there is no window to present a sheet on,
/// which is the accessory-installation tests' shape and reads as a popover with no row.
let settings: BoardSettingsPresentation?
/// The widget's two strings, computed fresh on every body evaluation rather than cached anywhere.
/// That matters here specifically: `boardInfoTitlebarAccessory` builds this view exactly **once**
/// at install, so a value read anywhere but inside `body` would freeze at the widget's birth and
/// never see a later rename or branch switch. `store.snapshot` and `git.branch` are both
/// `@Observable`, so reading them here is what makes the title and branch live.
private var summary: BoardInfoTitlebarSummary {
BoardInfoTitlebarSummary(
snapshotTitle: store.snapshot.title.value,
rootURL: store.rootURL,
mode: git?.mode ?? .none,
branch: git?.branch
)
}
var body: some View {
Button {
presentation.toggle()
} label: {
HStack(spacing: 4) {
// The board's own glyph beside its name — the identity pair the popover header
// states, restated where the board is named all day (2026-08-07). Read inside
// `body` for `summary`'s reason: `icon`/`iconColor` are `@Observable` fields, so a
// restyle from the popover repaints this widget without reinstalling it. Lenient on
// both dimensions — an unresolvable glyph draws the board default, an unresolvable
// tint draws the standard secondary.
Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board))
.imageScale(.small)
.foregroundStyle(iconTint)
// Decorative beside the name it repeats — the button's own label already says
// everything VoiceOver needs (`accessibilityLabel` below).
.accessibilityHidden(true)
Text(summary.title)
// Styled like a titlebar title, because that is what it now stands in for
// (`BoardWindowHost` hides the system title display in favor of this widget).
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
// Yields space to the branch string and chevron first when the two don't both
// fit inside the width cap below — the board's own name is the more load-bearing
// half of the pair.
.layoutPriority(1)
if let branch = summary.branch {
Text("—")
.foregroundStyle(.secondary)
Text(branch)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Image(systemName: "chevron.down")
.imageScale(.small)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
}
.font(.system(size: 13))
// A long board name (or branch) must not swallow the whole titlebar — capped rather
// than left to grow, with the truncation above doing the rest. Height stays the
// original chevron's, which is what keeps the accessory titlebar-appropriate.
.frame(maxWidth: 400, alignment: .leading)
.frame(height: 18)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Board Info")
.accessibilityLabel(accessibilityLabel)
.accessibilityHint("Shows board info")
// Populates the branch line the moment a git-mode board's window opens, rather than waiting
// on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover
// has actually been opened once). The widget is on screen from the start, so it is the
// earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this
// costs nothing on the other four postures.
.task { await git?.refreshBranch() }
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents, git: git, settings: settings)
}
}
/// What VoiceOver reads for the button, now that it says more than "Board Info": the board's
/// name, plus the branch when the widget is showing one — `.help` keeps the shorter "Board Info"
/// wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names what
/// the button does.
private var accessibilityLabel: String {
guard let branch = summary.branch else { return summary.title }
return "\(summary.title), branch \(branch)"
}
/// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
/// otherwise — `CardFaceView`'s `iconTint` rule at the board's own level.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: store.snapshot.iconColor) {
return AnyShapeStyle(color)
}
return AnyShapeStyle(.secondary)
}
}
// MARK: - The widget's strings
/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its
/// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same
/// reason `BoardGitSection.resolve` exists over in `BoardGitTabView.swift`.
///
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
/// menu, Exposé, VoiceOver and restoration even though the title bar's own rendering of it is now
/// hidden — see `BoardWindowHost.configureWindow`): the on-disk `title`, falling back to the folder
/// name sans extension when absent or empty (01-storage-format.md § Board naming). Restated here
/// against the raw title string and `rootURL` rather than a `BoardStore`, so this seam is testable
/// with plain values and no fixture board on disk — the one duplication this card leaves behind
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
///
/// **Branch.** Shown only when the board is actually git-mode — `mode == .git` with a non-`nil`
/// branch — the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause
/// is gone** (12-editions.md ▸ PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a
/// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode ==
/// .git` until that day is recorded here rather than restated in code. A board with no repository or
/// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows no branch; neither does a
/// git-mode board whose branch has not been read yet (`HistoryStore.branch` starts `nil` until
/// `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
struct BoardInfoTitlebarSummary: Equatable {
let title: String
let branch: String?
init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) {
if let snapshotTitle, !snapshotTitle.isEmpty {
self.title = snapshotTitle
} else {
self.title = rootURL.deletingPathExtension().lastPathComponent
}
self.branch = mode == .git ? branch : nil
}
}
/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
/// an `NSTitlebarAccessoryViewController` hosting the button, laid out `.leading` so it sits in the
/// title bar beside the window title rather than in the window's content.
///
/// `HostedWindowController.installTitlebarAccessory` owns the rest of the lifecycle — one per
/// window, removed on detach — for the same reason it owns the delegate proxying: the window is
/// SwiftUI's, and anything hung on it has to be taken back off.
@MainActor
/// `git` defaults to no session and `settings` to no sheet, so that a caller with none in hand (the
/// accessory-installation tests, which are about AppKit plumbing rather than about git) describes a
/// board honestly rather than by accident: a Git tab in its no-repository posture, and no
/// Board Settings… row behind it. The app's own call site passes the session's values explicitly.
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
git: HistoryStore? = nil,
presentation: BoardInfoPresentation,
settings: BoardSettingsPresentation? = nil
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
store: store,
recents: recents,
git: git,
presentation: presentation,
settings: settings
)
)
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
// as zero would be an invisible, unclickable widget. `.intrinsicContentSize` re-measures on every
// SwiftUI update, so this starting frame only has to survive the first layout pass before the
// widget's real content replaces it — but that first pass is exactly what a 20×18 placeholder
// (the old chevron-only width) would clamp now that the widget's content can run out to 400pt:
// wide enough that the widest realistic first paint is never visibly clipped before the resize.
hosting.sizingOptions = [.intrinsicContentSize]
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 18)
let controller = NSTitlebarAccessoryViewController()
controller.view = hosting
controller.layoutAttribute = .leading
return controller
}
// MARK: - Tabs
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
/// restructure — all three sessions settled): **Info** (`BoardInfoTabView`), **Theme**
/// (`BoardThemeTabView`), **Git** (`BoardGitTabView`). The raw values are the segmented control's own
/// labels, so the strip needs no separate label function.
enum BoardInfoTab: String, CaseIterable, Identifiable {
case info = "Info"
case theme = "Theme"
case git = "Git"
var id: Self { self }
// **Membership is the git posture's, and the posture never says "absent" any more.** The Git
// session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has
// something true to say, which then meant dropping it on a free board with no `.git` — the
// "absent, no placeholder" rule carried up to the strip. **12-editions.md ▸ PIVOT 2026-08-07**,
// the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and
// `.proPointer` died with it, and 03-board-ui.md ▸ Board popover records the consequence — "the
// absent posture is unreachable and every board carries all three tabs. The membership rule
// stands structurally — the strip still asks the posture — it just never hears 'absent'
// anymore." So there is no `available(…)` filter here to ask it with: membership is `allCases`,
// in `allCases`' own order (Info, Theme, Git), which is what the filter answered anyway.
}
// MARK: - The popover's content
/// The symbol/name header, then the tab bar, then the selected tab's surface — one view
/// (03-board-ui.md § Board popover).
///
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
/// beside a card — kept through the restructure so the popover's footprint didn't wander while the
/// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface
/// ever wants its own width remains open, but nothing has needed one yet.
struct BoardInfoView: View {
let store: BoardStore
let recents: StyleRecents
let git: HistoryStore?
let settings: BoardSettingsPresentation?
/// The selected tab, and **it resets to Info on every open** — a ruling, not an accident (the
/// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover
/// is transient and Info is the board's face, and a remembered tab could strand selection on a
/// tab the next board's posture doesn't offer. `@State` on the popover's content, which
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
@State private var tab: BoardInfoTab = .info
/// The style editor brings its own padding, so the sections around it carry the same number by
/// hand instead of an outer padding that would double up on it — **the editor's own figure**
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
/// with the grids inside it (10-accessibility.md's full-relative-scaling rule).
private var inset: CGFloat {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
init(
store: BoardStore,
recents: StyleRecents,
git: HistoryStore? = nil,
settings: BoardSettingsPresentation? = nil
) {
self.store = store
self.recents = recents
self.git = git
self.settings = settings
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
// The board's own icon, inline with its name — the same field the embedded style
// editor's symbol section below writes, offered here too since a board's identity
// is its name *and* its glyph together (03-board-ui.md § Styling ▸ Controls). No
// `undo:` — the board popover has none of its own, so this reaches the board's
// stack exactly as the embedded editor's writes do.
SymbolPicker(
current: store.snapshot.icon.value,
fallback: ItemSymbol.board,
onSelect: { name in
StyleCommand.apply(
icon: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
},
// The colour row — the picker's 4×2 tint grid, writing `iconColor` through
// the same funnel the glyph writes `icon`: None removes the key, a well
// writes the palette name (2026-08-07).
currentColor: store.snapshot.iconColor.value,
onSelectColor: { name in
StyleCommand.apply(
iconColor: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
}
)
.disabled(!store.acceptsBoardMutations)
BoardRenameField(store: store)
}
}
.padding(inset)
Divider()
// The tab bar: a segmented control rather than a `TabView`, because the popover is a
// compact settings surface and the segmented idiom is the macOS shape for switching
// between a handful of peer panes inside one. The label is hidden visually but stays
// the control's accessibility name. It iterates `allCases` — the strip's membership is
// still the board's git posture in principle (the Git session's ruling), and since
// 12-editions.md ▸ PIVOT 2026-08-07 that posture is never "absent", so every board
// carries all three tabs (03-board-ui.md ▸ Board popover, the same-day pivot note).
Picker("Board configuration", selection: $tab) {
ForEach(BoardInfoTab.allCases) { tab in
Text(tab.rawValue)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, inset)
.padding(.top, inset)
// The selected tab's surface — all three settled 2026-08-07, each in its own dedicated
// session and its own file. Each pads itself by `inset`, so the switch adds nothing.
switch tab {
case .info:
BoardInfoTabView(store: store, inset: inset)
case .theme:
BoardThemeTabView(store: store, inset: inset)
case .git:
BoardGitTabView(store: store, git: git, settings: settings, inset: inset)
}
}
// The style editor's popover width, taken from the editor rather than restated — the number
// that keeps a compact settings popover narrow enough to sit beside a card. It is
// font-derived, so the whole surface scales with the grids inside it (10-accessibility.md).
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
}
}
// MARK: - Rename
/// The board rename field (03-board-ui.md § Board popover; 01-storage-format.md § Board naming).
///
/// The exits are the inline editors' — Return and focus loss commit, Escape abandons — but the
/// **placeholder is the whole of the board-naming rule made visible**: an empty field shows the
/// folder name, because that is what the window title will say, and committing empty is how a user
/// asks for exactly that. "Untitled" appears nowhere; boards do not have it.
///
/// **It is not born focused**, unlike the three inline editors. Those are each opened by a gesture
/// that means "edit this now"; this one is one control among several on a configuration surface,
/// where the keyboard path is Tab-reachability rather than a caret waiting in the first field
/// (11-command-nexus.md's class **C**).
///
/// Every handler is idempotent, for `InlineTitleField`'s reason: the exits overlap by construction —
/// Return commits and then something takes focus, which fires the focus-loss commit an instant
/// later — and `BoardStore.renameBoard` skips an unchanged title, so the second call writes nothing.
private struct BoardRenameField: View {
let store: BoardStore
@State private var draft = ""
@FocusState private var isFocused: Bool
var body: some View {
TextField(fallbackName, text: $draft)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($isFocused)
.onSubmit { store.renameBoard(draft) }
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar): a dirty
// field abandons its edit and keeps the popover open, and an unedited one lets the press
// through to the popover's own dismissal. Reverting first is also what makes a dismissal
// safe on the paths where the press never reaches here — the focus-loss commit that
// follows sees a draft equal to what is on disk and writes nothing.
.onKeyPress(.escape) {
guard draft != committed else { return .ignored }
draft = committed
return .handled
}
.onChange(of: isFocused) { _, focused in
guard !focused else { return }
store.renameBoard(draft)
}
.onAppear { draft = committed }
// A foreign rename — an agent, a hand edit, a sync — landing behind an open popover
// updates the field, but never under the user's fingers: a draft being typed is the
// user's, and the reload is not an edit to it (02-architecture.md § Live-reload
// resilience, the same courtesy the inline editors get by tracking their UUID).
.onChange(of: committed) { _, title in
guard !isFocused else { return }
draft = title
}
// The popover can be dismissed without the field ever reporting focus loss, and a
// dismissal is a commit like any other click-away. Idempotent with the handler above.
.onDisappear { store.renameBoard(draft) }
// The read-only lock disables every mutating surface (02-architecture.md § The lock's
// scope) — and the popover *stays open* under it, which is the style popover's settled
// precedent: the lock is a condition the banner is already explaining, not a reason to
// yank a surface away. `acceptsBoardMutations` rather than `isReadOnly` alone so this
// field and the editor below it disable as one surface rather than in halves.
.disabled(!store.acceptsBoardMutations)
}
/// The `title` on disk, as the last reload read it — "" for a board that has none.
private var committed: String { store.snapshot.title.value ?? "" }
/// The folder name, sans extension: what the window title shows when `title` is absent
/// (01-storage-format.md § Board naming), and therefore the honest thing for an empty field to
/// promise. Read off `rootURL` rather than `snapshot.rootURL` so a Finder rename absorbed
/// mid-session shows here immediately.
private var fallbackName: String {
store.rootURL.deletingPathExtension().lastPathComponent
}
}