The board settings sheet — setup leaves the popover for a home a stray click can't dismiss

The popover/sheet split lands: a board-scoped, titled, sectioned sheet on
the board window hosts everything setup-shaped, opened from the popover's
Board Settings… row and the new Board ▸ Board Settings… menu row. The three
existing setup controls relocate — add-git (whose noteFormVisible lines now
make the sheet the form-anchored failure surface), branch creation (a
standing field; create-and-switch runs the identical settle sequence), and
the commit-identity fields (the 2s visibility-scoped poll rides with them).
The popover keeps the daily face and its postures; its Pro/mode-none
section becomes header + door (.addGit renamed .noRepository). Availability
is derived from the section inventory (Pro + mode none or git), so pro-m2's
sections can't drift from the doors; the sheet's fields join the
caret-chord disable set. The audit suite pins what the free fixture can
reach; the sheet's own audit is manual until a tier override is ruled.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 12:07:12 -04:00
parent 988a7245a3
commit 8345378972
12 changed files with 1168 additions and 321 deletions
+79 -63
View File
@@ -84,6 +84,12 @@ struct BoardInfoWidget: View {
@Bindable var presentation: BoardInfoPresentation
/// The window's settings sheet, so the popover's git section 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
@@ -149,7 +155,7 @@ struct BoardInfoWidget: View {
// costs nothing on the other four postures.
.task { await git?.refreshBranch() }
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents, tier: tier, git: git)
BoardInfoView(store: store, recents: recents, tier: tier, git: git, settings: settings)
}
}
@@ -207,16 +213,17 @@ struct BoardInfoTitlebarSummary: Equatable {
/// 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
/// `tier`/`git` default to the free tier's posture a popover with no git section at all so that
/// a caller with no session in hand (the accessory-installation tests, which are about AppKit
/// plumbing rather than about git) describes a board honestly rather than by accident. The app's own
/// call site passes the session's values explicitly.
/// `tier`/`git` default to the free tier's posture a popover with no git section at all and
/// `settings` to no sheet, so that a caller with no session in hand (the accessory-installation
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
/// than by accident. The app's own call site passes the session's values explicitly.
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
tier: Tier = .free,
git: HistoryStore? = nil,
presentation: BoardInfoPresentation
presentation: BoardInfoPresentation,
settings: BoardSettingsPresentation? = nil
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
@@ -224,7 +231,8 @@ func boardInfoTitlebarAccessory(
recents: recents,
tier: tier,
git: git,
presentation: presentation
presentation: presentation,
settings: settings
)
)
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
@@ -256,6 +264,13 @@ struct BoardInfoView: View {
let recents: StyleRecents
let tier: Tier
let git: HistoryStore?
let settings: BoardSettingsPresentation?
/// **The popover's own dismissal**, used by exactly one control: the Board Settings row, whose
/// job is to close this surface and open the sheet. The popover is presented by `isPresented`, so
/// the environment action drives the same flag the widget's button does nothing here has to be
/// handed the widget's binding to put it down.
@Environment(\.dismiss) private var dismiss
/// Whether this board carries a `.git` checked once, off disk, when the view is built (which
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
@@ -274,11 +289,18 @@ struct BoardInfoView: View {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
init(store: BoardStore, recents: StyleRecents, tier: Tier = .free, git: HistoryStore? = nil) {
init(
store: BoardStore,
recents: StyleRecents,
tier: Tier = .free,
git: HistoryStore? = nil,
settings: BoardSettingsPresentation? = nil
) {
self.store = store
self.recents = recents
self.tier = tier
self.git = git
self.settings = settings
// Asked only where it is the answer: under Pro the mode already knows, and a free-tier
// board is the only one this question is for (12-editions.md The free tier and `.git`).
self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL)
@@ -331,13 +353,14 @@ struct BoardInfoView: View {
BoardGitNote()
.padding(inset)
case .addGit:
case .noRepository:
// Nothing daily to show on a board with no repository so the section is the door and
// its header. Add-git itself moved to the sheet with the 2026-07-31 split; what stays
// here is the honest signpost that this board *could* have a history and where to say so.
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
if let git {
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
}
boardSettingsRow
}
.padding(inset)
@@ -356,11 +379,37 @@ struct BoardInfoView: View {
if let git {
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
}
boardSettingsRow
}
.padding(inset)
}
}
/// **The popover's one setup affordance** (03-board-ui.md Board popover) the sheet's first
/// door, the menu row being the second (11-command-nexus.md).
///
/// **Shown only where the sheet is reachable** (`BoardSettingsAvailability`): a popover section
/// describes *this board*, so a row pointing at a surface this board cannot have would be the
/// disabled button 06 rules out one level up. The menu row is the opposite case and stays visible
/// a menu is an inventory of the app.
///
/// **Dismiss first, then present.** The popover is transient and the sheet is not; leaving a
/// transient surface hanging over a modal one would read as two surfaces arguing about which the
/// user is in.
///
/// Not disabled by the read-only lock: opening a configuration surface is not a mutation, and the
/// controls inside it disable themselves (the Board Info I rule).
@ViewBuilder
private var boardSettingsRow: some View {
if let settings, BoardSettingsAvailability.resolve(tier: tier, mode: git?.mode ?? .none) {
Button("Board Settings…") {
dismiss()
settings.present()
}
.accessibilityHint("Opens the board settings sheet")
}
}
/// The section titles, matching the style editor's own headers so the popover reads as one
/// surface rather than borrowed ones.
private func sectionHeader(_ title: String) -> some View {
@@ -456,6 +505,13 @@ private struct BoardRenameField: View {
/// mode, one to one and the mode-`none` and repo-nested pair is where the design is most
/// insistent: a repo-nested board gets **prose, not a disabled button**. "The option is absent
/// because it *can't* apply, and the UI should teach that rather than look broken" (06 Rules).
///
/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup
/// left the popover for the board settings sheet, so mode `none` no longer renders an action here at
/// all (the case was called `.addGit` when it did a name that would now be describing a control
/// that lives in another file, so it is `.noRepository`), and the git-mode case lost branch creation
/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its
/// test survived the move unchanged.
enum BoardGitSection: Equatable, CaseIterable {
/// Nothing at all the free tier's ordinary board, where "the popover is rename + style,
@@ -466,16 +522,19 @@ enum BoardGitSection: Equatable, CaseIterable {
/// to Pro (12 Tier naming).
case proPointer
/// Pro, mode `none`: the add-git action (06 Rules Opt-in init).
case addGit
/// Pro, mode `none`: a board that could have a history and has none. There is no daily surface
/// for that the section is the header and the Board Settings row, where add-git now lives
/// (03 Board settings sheet).
case noRepository
/// Pro, repo-nested: the honest explanation, no action.
/// Pro, repo-nested: the honest explanation, no action and no Board Settings row either,
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
case repoNested
/// Pro, git mode: the branch/source line with switching and creation, the abnormal-state
/// explanation when the surface is held, and the commit-identity fields (`BoardGitControls`).
/// The remote half tracking, Pull/Push, push-on-commit, authentication is 07-sync-collab.md's
/// own card and joins this same posture.
/// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state
/// explanation when the surface is held, and the Board Settings row. The remote half
/// tracking, Pull/Push, the status badges is 07-sync-collab.md's own card and joins this same
/// posture.
case branch
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
@@ -487,7 +546,7 @@ enum BoardGitSection: Equatable, CaseIterable {
return hasGitDirectory ? .proPointer : .absent
case .pro:
switch mode {
case .none: return .addGit
case .none: return .noRepository
case .git: return .branch
case .repoNested: return .repoNested
}
@@ -495,49 +554,6 @@ enum BoardGitSection: Equatable, CaseIterable {
}
}
/// **The add-git action** (06-history-undo.md Rules Opt-in init) the one place in the app that
/// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than
/// a promise: there is no other caller of `HistoryStore.addGit`.
///
/// The caption states what pressing it does, in the order it happens, because it is not undoable in
/// the ordinary sense: a repository appears in the board's folder and its current state becomes the
/// first commit.
private struct BoardGitAddAction: View {
let git: HistoryStore
/// The read-only lock's reach (02-architecture.md The lock's scope): a board that refuses
/// writes refuses this one too initializing a repository is a write, and a commit is several.
let isEnabled: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Button("Add Git") {
Task { await git.addGit() }
}
.disabled(!isEnabled || git.isAddingGit)
Text("Creates a git repository in this board's folder and commits its current state.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let failure = git.lastFailure {
Text(failure.message)
.font(.caption)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
// **The form add-git answers at** (06 Interaction with external writers, ruled 2026-07-31
// "Form-anchored operations answer at the form first"; its container moved to the board
// settings sheet in the 2026-07-31 split, and these two lines are what that sheet re-points).
// Appearing claims the inline surface; disappearing gives it up, which both dismisses the
// stale error and sends any answer still in flight to the banner instead of to nobody.
.onAppear { git.noteFormVisible(true) }
.onDisappear { git.noteFormVisible(false) }
}
}
/// **The repo-nested explanation** (06-history-undo.md Rules), worded as the design words it:
/// short prose in place of an action, never a hidden or greyed-out add-git.
private struct BoardGitNestedNote: View {