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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user