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:
@@ -57,6 +57,12 @@ struct BoardWindowHost: View {
|
|||||||
/// menu item through the focus system, like the store.
|
/// menu item through the focus system, like the store.
|
||||||
@State private var boardInfo = BoardInfoPresentation()
|
@State private var boardInfo = BoardInfoPresentation()
|
||||||
|
|
||||||
|
/// This window's board settings sheet, open or not (03-board-ui.md § Board settings sheet).
|
||||||
|
/// `@State` for `boardInfo`'s reason — one per window — and published the same way, because
|
||||||
|
/// Board ▸ Board Settings… is a menu-bar item that has to reach the frontmost board window, and
|
||||||
|
/// because the sheet is presented *on this window* and modal to it.
|
||||||
|
@State private var boardSettings = BoardSettingsPresentation()
|
||||||
|
|
||||||
/// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
|
/// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
|
||||||
/// reason and reaching the menu bar the same way: File ▸ Delete (landing on a trash selection)
|
/// reason and reaching the menu bar the same way: File ▸ Delete (landing on a trash selection)
|
||||||
/// and Empty Trash… are menu-bar items, and a menu item cannot present anything of its own.
|
/// and Empty Trash… are menu-bar items, and a menu item cannot present anything of its own.
|
||||||
@@ -197,6 +203,17 @@ struct BoardWindowHost: View {
|
|||||||
.onChange(of: boardSearch.isFocused) { _, _ in
|
.onChange(of: boardSearch.isFocused) { _, _ in
|
||||||
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
|
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
|
||||||
}
|
}
|
||||||
|
// **The board settings sheet** (03-board-ui.md ▸ Board settings sheet) — presented from
|
||||||
|
// the board window's own content, which is what makes it modal to *this* board rather
|
||||||
|
// than to the app: "a board-scoped, titled, sectioned sheet on the board window".
|
||||||
|
//
|
||||||
|
// It hangs here rather than inside `BoardView` for the reason the popover's flag is a
|
||||||
|
// window's: the two doors that open it are the titlebar popover's row and a menu-bar
|
||||||
|
// item, neither of which is inside the board, and a sheet has to be presented by
|
||||||
|
// something that outlives the surface that asked for it.
|
||||||
|
.sheet(isPresented: $boardSettings.isPresented) {
|
||||||
|
BoardSettingsSheet(store: store, presentation: boardSettings)
|
||||||
|
}
|
||||||
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
|
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
|
||||||
// beside it the window's own popover flag, which is what File ▸ Board Info toggles, its
|
// beside it the window's own popover flag, which is what File ▸ Board Info toggles, its
|
||||||
// purge-alert host, which the trash's two confirmed commands raise, and its search
|
// purge-alert host, which the trash's two confirmed commands raise, and its search
|
||||||
@@ -207,6 +224,7 @@ struct BoardWindowHost: View {
|
|||||||
// is keyed on the window rather than on the board it is showing.
|
// is keyed on the window rather than on the board it is showing.
|
||||||
.focusedSceneValue(\.boardWindowRef, ref)
|
.focusedSceneValue(\.boardWindowRef, ref)
|
||||||
.focusedSceneValue(\.boardInfo, boardInfo)
|
.focusedSceneValue(\.boardInfo, boardInfo)
|
||||||
|
.focusedSceneValue(\.boardSettings, boardSettings)
|
||||||
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
|
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
|
||||||
// Board ▸ Open Card's second half — the same closure `BoardView` gets, so the menu item
|
// Board ▸ Open Card's second half — the same closure `BoardView` gets, so the menu item
|
||||||
// and the double-click open one window per card by construction.
|
// and the double-click open one window per card by construction.
|
||||||
@@ -694,13 +712,21 @@ struct BoardWindowHost: View {
|
|||||||
// happen on this path — `beginSession` precedes `configureWindow` — and reads as the free
|
// happen on this path — `beginSession` precedes `configureWindow` — and reads as the free
|
||||||
// tier's posture, which is the harmless direction.
|
// tier's posture, which is the harmless direction.
|
||||||
let session = appModel.session(for: ref)
|
let session = appModel.session(for: ref)
|
||||||
|
// The settings sheet's two doors validate on the same pair, so they are adopted here rather
|
||||||
|
// than read again somewhere else: the popover's Board Settings… row and Board ▸ Board
|
||||||
|
// Settings… must never disagree about whether this board has setup to show
|
||||||
|
// (`BoardSettingsAvailability`). The mode *inside* the git state stays live — add-git flipping
|
||||||
|
// it re-resolves the sheet's sections and both doors, which is the one mid-session transition
|
||||||
|
// 06 sanctions.
|
||||||
|
boardSettings.adopt(tier: session?.tier ?? .free, git: session?.git)
|
||||||
windowController.installTitlebarAccessory(
|
windowController.installTitlebarAccessory(
|
||||||
boardInfoTitlebarAccessory(
|
boardInfoTitlebarAccessory(
|
||||||
store: store,
|
store: store,
|
||||||
recents: appModel.styleRecents,
|
recents: appModel.styleRecents,
|
||||||
tier: session?.tier ?? .free,
|
tier: session?.tier ?? .free,
|
||||||
git: session?.git,
|
git: session?.git,
|
||||||
presentation: boardInfo
|
presentation: boardInfo,
|
||||||
|
settings: boardSettings
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
// The widget above now says the board's name (and, on a git-mode Pro board, its branch)
|
// The widget above now says the board's name (and, on a git-mode Pro board, its branch)
|
||||||
|
|||||||
@@ -70,9 +70,10 @@ public final class HistoryStore {
|
|||||||
/// dismissing the form clears it ("dismissing the sheet dismisses the stale error"). The other
|
/// dismissing the form clears it ("dismissing the sheet dismisses the stale error"). The other
|
||||||
/// half is `reportFailure`, which posts the banner when the answer arrives to an empty room.
|
/// half is `reportFailure`, which posts the banner when the answer arrives to an empty room.
|
||||||
///
|
///
|
||||||
/// The form is the popover's git section today and the board settings sheet once that exists —
|
/// The form is the **board settings sheet's Git section** (`BoardSettingsSheet`), which is where
|
||||||
/// the ruling's container moved in the 2026-07-31 popover/sheet split, its substance did not, and
|
/// the 2026-07-31 popover/sheet split put add-git; it was the popover's git section until that
|
||||||
/// `noteFormVisible(_:)` is the one line the sheet will re-point.
|
/// sheet was built, and `noteFormVisible(_:)` is the one line the move re-pointed — the ruling's
|
||||||
|
/// container changed, its substance did not.
|
||||||
public private(set) var lastFailure: GitOperationFailure?
|
public private(set) var lastFailure: GitOperationFailure?
|
||||||
|
|
||||||
/// Whether the form add-git was asked from is on screen right now (`noteFormVisible(_:)`).
|
/// Whether the form add-git was asked from is on screen right now (`noteFormVisible(_:)`).
|
||||||
@@ -114,15 +115,15 @@ public final class HistoryStore {
|
|||||||
|
|
||||||
// MARK: - Commit identity
|
// MARK: - Commit identity
|
||||||
|
|
||||||
/// **What repo-local `.git/config` says right now** — the popover's two fields, as values rather
|
/// **What repo-local `.git/config` says right now** — the settings sheet's two fields, as values
|
||||||
/// than as a resolved identity (06 ▸ Interaction with external writers: "The board popover's git
|
/// rather than as a resolved identity (06 ▸ Interaction with external writers: "The board settings
|
||||||
/// section exposes name/email fields that write that repo-local config — the setting *is* the
|
/// sheet's identity section … exposes name/email fields that write that repo-local config — the
|
||||||
/// file").
|
/// setting *is* the file").
|
||||||
///
|
///
|
||||||
/// Empty means the file names no such key, which is what an empty field means: the derived default
|
/// Empty means the file names no such key, which is what an empty field means: the derived default
|
||||||
/// applies, shown as the field's *placeholder*. Filling the field in with the derived value would
|
/// applies, shown as the field's *placeholder*. Filling the field in with the derived value would
|
||||||
/// be the app writing its own guess into the user's repository the first time they edited anything
|
/// be the app writing its own guess into the user's repository the first time they edited anything
|
||||||
/// else in the popover — the exact thing 06 rules out.
|
/// else on the sheet — the exact thing 06 rules out.
|
||||||
public private(set) var identityName = ""
|
public private(set) var identityName = ""
|
||||||
|
|
||||||
public private(set) var identityEmail = ""
|
public private(set) var identityEmail = ""
|
||||||
@@ -132,12 +133,12 @@ public final class HistoryStore {
|
|||||||
/// Deliberately not computed at composition: `GitIdentity.derivedDefault()` reads
|
/// Deliberately not computed at composition: `GitIdentity.derivedDefault()` reads
|
||||||
/// `ProcessInfo.hostName`, which can block on a machine whose name resolution is slow, and the
|
/// `ProcessInfo.hostName`, which can block on a machine whose name resolution is slow, and the
|
||||||
/// board-open path is where 02-architecture.md's hang-avoidance doctrine is strictest. It is read
|
/// board-open path is where 02-architecture.md's hang-avoidance doctrine is strictest. It is read
|
||||||
/// off the main actor with the config, when the popover asks.
|
/// off the main actor with the config, when the sheet asks.
|
||||||
public private(set) var derivedIdentity: GitIdentity?
|
public private(set) var derivedIdentity: GitIdentity?
|
||||||
|
|
||||||
/// The last identity-write failure, surfaced as an inline caption in the popover beside the fields
|
/// The last identity-write failure, surfaced as an inline caption on the settings sheet beside the
|
||||||
/// — 06's popover-anchored posture ("the user asked from a form still under their eye"), which is
|
/// fields — 06's form-anchored posture ("the user asked from a form still under their eye"), which
|
||||||
/// exactly where `lastFailure` above already puts add-git's.
|
/// is exactly where `lastFailure` above already puts add-git's.
|
||||||
public private(set) var identityFailure: GitOperationFailure?
|
public private(set) var identityFailure: GitOperationFailure?
|
||||||
|
|
||||||
/// The board's write-provenance ledger, held so an add-git flip can build a committer over the
|
/// The board's write-provenance ledger, held so an add-git flip can build a committer over the
|
||||||
@@ -173,8 +174,8 @@ public final class HistoryStore {
|
|||||||
/// **The form appeared or was dismissed.** Dismissal clears the stale inline error, which is the
|
/// **The form appeared or was dismissed.** Dismissal clears the stale inline error, which is the
|
||||||
/// ruling's own sentence ("dismissing the sheet dismisses the stale error, retry is right there").
|
/// ruling's own sentence ("dismissing the sheet dismisses the stale error, retry is right there").
|
||||||
///
|
///
|
||||||
/// A `Bool` rather than a count because there is one such form per board at a time: the popover is
|
/// A `Bool` rather than a count because there is one such form per board at a time: the settings
|
||||||
/// built fresh on each open and the settings sheet is modal to its board window.
|
/// sheet is modal to its board window, and opening it dismisses the popover.
|
||||||
public func noteFormVisible(_ visible: Bool) {
|
public func noteFormVisible(_ visible: Bool) {
|
||||||
isFormVisible = visible
|
isFormVisible = visible
|
||||||
if !visible { lastFailure = nil }
|
if !visible { lastFailure = nil }
|
||||||
@@ -258,14 +259,18 @@ public final class HistoryStore {
|
|||||||
/// **Opt-in init** (06-history-undo.md ▸ Rules): initializes a repository at the board root and
|
/// **Opt-in init** (06-history-undo.md ▸ Rules): initializes a repository at the board root and
|
||||||
/// immediately commits the whole tree as "Initial board state".
|
/// immediately commits the whole tree as "Initial board state".
|
||||||
///
|
///
|
||||||
/// Reachable from one place — the board popover's git section under Pro — and from nowhere else:
|
/// Reachable from one place — the board settings sheet's Git section under Pro — and from nowhere
|
||||||
|
/// else:
|
||||||
/// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo
|
/// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo
|
||||||
/// under every board it opened.
|
/// under every board it opened.
|
||||||
///
|
///
|
||||||
/// **It flips the open board's mode immediately**, which is the design's one sanctioned
|
/// **It flips the open board's mode immediately**, which is the design's one sanctioned
|
||||||
/// mid-session transition: "clicking it flips the open board into git mode immediately — the
|
/// mid-session transition: "clicking it flips the open board into git mode immediately — the
|
||||||
/// popover flows straight into the git controls". The flip is commanded, not discovered, which
|
/// popover flows straight into the git controls". Since the 2026-07-31 split that flow is one
|
||||||
/// is what distinguishes it from the `git init` a user runs in a terminal under an open board.
|
/// surface further along — the sheet's Git section becomes its Branch and Commit Identity
|
||||||
|
/// sections, and the popover behind it gains the branch line — but the immediacy is the same. The
|
||||||
|
/// flip is commanded, not discovered, which is what distinguishes it from the `git init` a user
|
||||||
|
/// runs in a terminal under an open board.
|
||||||
///
|
///
|
||||||
/// Only mode `none` can be added to. Mode `git` has nothing to add, and a repo-nested board is
|
/// Only mode `none` can be added to. Mode `git` has nothing to add, and a repo-nested board is
|
||||||
/// one the app "leaves strictly alone" — no nested repo, ever.
|
/// one the app "leaves strictly alone" — no nested repo, ever.
|
||||||
@@ -279,7 +284,7 @@ public final class HistoryStore {
|
|||||||
|
|
||||||
let root = boardRoot
|
let root = boardRoot
|
||||||
// Off the main actor: `git_repository_init` plus a whole-tree stage and commit is real
|
// Off the main actor: `git_repository_init` plus a whole-tree stage and commit is real
|
||||||
// filesystem work, and the popover it was clicked in stays live while it runs.
|
// filesystem work, and the sheet it was clicked in stays live while it runs.
|
||||||
let outcome = await Task.detached(priority: .userInitiated) {
|
let outcome = await Task.detached(priority: .userInitiated) {
|
||||||
GitRepository.create(at: root)
|
GitRepository.create(at: root)
|
||||||
}.value
|
}.value
|
||||||
@@ -326,7 +331,8 @@ public final class HistoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the current branch name into `branch` — the popover's read-only display line, refreshed
|
/// Reads the current branch name into `branch` — the popover's read-only display line, refreshed
|
||||||
/// when the popover opens. A no-op outside git mode.
|
/// when the popover opens (and when the settings sheet does, whose create control reads the same
|
||||||
|
/// surface). A no-op outside git mode.
|
||||||
public func refreshBranch() async {
|
public func refreshBranch() async {
|
||||||
guard mode == .git else { return }
|
guard mode == .git else { return }
|
||||||
let root = boardRoot
|
let root = boardRoot
|
||||||
@@ -337,8 +343,8 @@ public final class HistoryStore {
|
|||||||
|
|
||||||
// MARK: - Commit identity
|
// MARK: - Commit identity
|
||||||
|
|
||||||
/// Reads repo-local config and the derived default into the popover's fields. A no-op outside git
|
/// Reads repo-local config and the derived default into the settings sheet's fields. A no-op
|
||||||
/// mode, `refreshBranch()`'s rule.
|
/// outside git mode, `refreshBranch()`'s rule.
|
||||||
///
|
///
|
||||||
/// Both reads run off the main actor: one opens a repository, the other asks the system for the
|
/// Both reads run off the main actor: one opens a repository, the other asks the system for the
|
||||||
/// account and host names.
|
/// account and host names.
|
||||||
|
|||||||
+13
-4
@@ -252,10 +252,15 @@ struct KanbanApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
||||||
// Card, Rename, Style…, the card moves, the lane moves, the width pair, then the remote pair.
|
// Card, Rename, Style…, the card moves, the lane moves, the width pair, the remote pair, then
|
||||||
// Its items act on the frontmost board window, which they reach through the focus system
|
// Board Settings…. Its items act on the frontmost board window, which they reach through the
|
||||||
// rather than through the app model — see `BoardCommands.swift`, which also owns their
|
// focus system rather than through the app model — see `BoardCommands.swift`, which also owns
|
||||||
// validation.
|
// their validation.
|
||||||
|
//
|
||||||
|
// Board Settings… sits below a divider rather than beside Pull/Push: the Nexus lists it last
|
||||||
|
// in this menu, and the two families differ — Pull and Push are recurring *operations* on a
|
||||||
|
// remote, and the sheet is where a board is *set up* (03 ▸ Board settings sheet, the
|
||||||
|
// 2026-07-31 popover/sheet split).
|
||||||
CommandMenu("Board") {
|
CommandMenu("Board") {
|
||||||
OpenCardCommand()
|
OpenCardCommand()
|
||||||
BoardRenameCommand()
|
BoardRenameCommand()
|
||||||
@@ -273,6 +278,10 @@ struct KanbanApp: App {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
RemoteCommands()
|
RemoteCommands()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
BoardSettingsCommand()
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandGroup(after: .windowList) {
|
CommandGroup(after: .windowList) {
|
||||||
|
|||||||
@@ -87,13 +87,17 @@ extension BoardStore {
|
|||||||
/// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the
|
/// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the
|
||||||
/// > standard line-start/end caret chords.
|
/// > standard line-start/end caret chords.
|
||||||
///
|
///
|
||||||
/// Four text surfaces, answered four ways, and only two of them are here:
|
/// Five text surfaces, answered four ways, and only three of them are here:
|
||||||
///
|
///
|
||||||
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule — a
|
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule — a
|
||||||
/// broader lockdown that already covers these two items.
|
/// broader lockdown that already covers these two items.
|
||||||
/// - **The board popover's fields** are covered by disabling while the popover is open at all —
|
/// - **The board popover's fields** are covered by disabling while the popover is open at all —
|
||||||
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
|
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
|
||||||
/// move belongs under it.
|
/// move belongs under it.
|
||||||
|
/// - **The board settings sheet's fields** are the popover's rule for the popover's reason, and the
|
||||||
|
/// surface the 2026-07-31 split moved most of those fields *to* (branch name, commit identity, and
|
||||||
|
/// pro-m2's remote URL and credentials): the sheet is the other half of 04's configuration
|
||||||
|
/// carve-out, and a lane move under a modal settings surface is not a gesture that exists.
|
||||||
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
|
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
|
||||||
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
|
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
|
||||||
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
|
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
|
||||||
@@ -103,8 +107,14 @@ extension BoardStore {
|
|||||||
/// Stated once because the two command groups must not drift: a rule with two implementations is a
|
/// Stated once because the two command groups must not drift: a rule with two implementations is a
|
||||||
/// rule with two chances to forget a surface.
|
/// rule with two chances to forget a surface.
|
||||||
@MainActor
|
@MainActor
|
||||||
func caretChordsYield(boardInfo: BoardInfoPresentation?, search: BoardSearchPresentation?) -> Bool {
|
func caretChordsYield(
|
||||||
boardInfo?.isPresented == true || search?.isFocused == true
|
boardInfo: BoardInfoPresentation?,
|
||||||
|
boardSettings: BoardSettingsPresentation?,
|
||||||
|
search: BoardSearchPresentation?
|
||||||
|
) -> Bool {
|
||||||
|
boardInfo?.isPresented == true
|
||||||
|
|| boardSettings?.isPresented == true
|
||||||
|
|| search?.isFocused == true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Open Card
|
// MARK: - Open Card
|
||||||
@@ -276,11 +286,13 @@ struct MoveCardCommands: View {
|
|||||||
/// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled):
|
/// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled):
|
||||||
/// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a
|
/// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a
|
||||||
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
|
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
|
||||||
/// `caretChordsYield(boardInfo:search:)`'s doc comment — shared verbatim with the width pair below.
|
/// `caretChordsYield(boardInfo:boardSettings:search:)`'s doc comment — shared verbatim with the
|
||||||
|
/// width pair below.
|
||||||
struct MoveLaneCommands: View {
|
struct MoveLaneCommands: View {
|
||||||
|
|
||||||
@FocusedValue(\.boardStore) private var store
|
@FocusedValue(\.boardStore) private var store
|
||||||
@FocusedValue(\.boardInfo) private var boardInfo
|
@FocusedValue(\.boardInfo) private var boardInfo
|
||||||
|
@FocusedValue(\.boardSettings) private var boardSettings
|
||||||
@FocusedValue(\.boardSearch) private var search
|
@FocusedValue(\.boardSearch) private var search
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -298,7 +310,7 @@ struct MoveLaneCommands: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var yieldsCaretChords: Bool {
|
private var yieldsCaretChords: Bool {
|
||||||
caretChordsYield(boardInfo: boardInfo, search: search)
|
caretChordsYield(boardInfo: boardInfo, boardSettings: boardSettings, search: search)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sole selected live lane and the display slot one step would put it in — `nil` when there
|
/// The sole selected live lane and the display slot one step would put it in — `nil` when there
|
||||||
@@ -415,6 +427,48 @@ struct BoardInfoCommand: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Board Settings
|
||||||
|
|
||||||
|
/// Board ▸ Board Settings… — no default chord (11-command-nexus.md: "— (no default)"), and the board
|
||||||
|
/// settings sheet's second door, the popover's row being the first (03-board-ui.md ▸ Board settings
|
||||||
|
/// sheet).
|
||||||
|
///
|
||||||
|
/// **"Remappable" asks nothing of this file.** The remapping mechanism is macOS's own — System
|
||||||
|
/// Settings ▸ Keyboard ▸ App Shortcuts, keyed on the menu item's *title* (04-interactions.md ▸
|
||||||
|
/// Configurable bindings) — so all a chordless row owes it is a stable title, which the Nexus fixes.
|
||||||
|
/// A `Button` with no `keyboardShortcut` is therefore the whole implementation, exactly as File ▸
|
||||||
|
/// Save as Template and Board ▸ Rename are.
|
||||||
|
///
|
||||||
|
/// ### It opens; it never toggles
|
||||||
|
///
|
||||||
|
/// Board Info ⌘I toggles because a popover reached by a chord would otherwise have no keyboard way
|
||||||
|
/// out. A sheet has one built in (Done, and Escape through it), and the menu is behind the sheet
|
||||||
|
/// while it is up — so a toggling row would be a second exit nobody can reach.
|
||||||
|
///
|
||||||
|
/// ### Validation: scope, then reachability — and never the lock
|
||||||
|
///
|
||||||
|
/// The row stays **visible and disabled** where the sheet cannot exist (`BoardSettingsAvailability`,
|
||||||
|
/// which carries the reasoning): the free tier, and a Pro board nested inside someone else's
|
||||||
|
/// repository. That is standard menu validation, and it is the deliberate asymmetry with the popover
|
||||||
|
/// row, which is *absent* there instead — a menu is an inventory of the app, a popover section is a
|
||||||
|
/// description of this board.
|
||||||
|
///
|
||||||
|
/// The read-only lock does not close it, for Board Info's reason: a settings sheet is *configuration*
|
||||||
|
/// (04-interactions.md ▸ The map's carve-out), a locked board is exactly when a user may want to read
|
||||||
|
/// its git setup, and the controls inside disable themselves in place (03 ▸ Board settings sheet).
|
||||||
|
struct BoardSettingsCommand: View {
|
||||||
|
|
||||||
|
@FocusedValue(\.boardStore) private var store
|
||||||
|
@FocusedValue(\.boardSettings) private var presentation
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button("Board Settings…") {
|
||||||
|
presentation?.present()
|
||||||
|
}
|
||||||
|
.disabled(store == nil || presentation?.isReachable != true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Rename
|
// MARK: - Rename
|
||||||
|
|
||||||
/// Board ▸ Rename — no default chord, deliberately (11-command-nexus.md: "— (cards: Return in
|
/// Board ▸ Rename — no default chord, deliberately (11-command-nexus.md: "— (cards: Return in
|
||||||
@@ -491,6 +545,7 @@ struct LaneWidthCommands: View {
|
|||||||
|
|
||||||
@FocusedValue(\.boardStore) private var store
|
@FocusedValue(\.boardStore) private var store
|
||||||
@FocusedValue(\.boardInfo) private var boardInfo
|
@FocusedValue(\.boardInfo) private var boardInfo
|
||||||
|
@FocusedValue(\.boardSettings) private var boardSettings
|
||||||
@FocusedValue(\.boardSearch) private var search
|
@FocusedValue(\.boardSearch) private var search
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -508,7 +563,7 @@ struct LaneWidthCommands: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var yieldsCaretChords: Bool {
|
private var yieldsCaretChords: Bool {
|
||||||
caretChordsYield(boardInfo: boardInfo, search: search)
|
caretChordsYield(boardInfo: boardInfo, boardSettings: boardSettings, search: search)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selected live lanes, in snapshot order — the batch, and the items' validation.
|
/// The selected live lanes, in snapshot order — the batch, and the items' validation.
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ struct BoardGitBranchSurface: Equatable {
|
|||||||
/// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live.
|
/// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live.
|
||||||
let pauseExplanation: String?
|
let pauseExplanation: String?
|
||||||
|
|
||||||
/// Whether the branch picker and the create action accept a click.
|
/// Whether the branch controls accept a click — the popover's switch picker, and the settings
|
||||||
|
/// sheet's create field (`BoardSettingsSheet`), which resolves this same surface so that a
|
||||||
|
/// paused repository, a read-only board and a switch in flight close both by one rule.
|
||||||
let controlsEnabled: Bool
|
let controlsEnabled: Bool
|
||||||
|
|
||||||
/// The line the branch display is read as by VoiceOver.
|
/// The line the branch display is read as by VoiceOver.
|
||||||
@@ -61,16 +63,17 @@ struct BoardGitBranchSurface: Equatable {
|
|||||||
// MARK: - The git-mode section
|
// MARK: - The git-mode section
|
||||||
|
|
||||||
/// **The popover's git section on a board that has a repository** (03-board-ui.md ▸ Board popover;
|
/// **The popover's git section on a board that has a repository** (03-board-ui.md ▸ Board popover;
|
||||||
/// 06-history-undo.md ▸ Branch switching, ▸ Interaction with external writers).
|
/// 06-history-undo.md ▸ Branch switching).
|
||||||
///
|
///
|
||||||
/// Three surfaces, in the order the design lists them: the branch display with switching and
|
/// **The daily face, and only that** (the 2026-07-31 popover/sheet split): the branch display with
|
||||||
/// creation, the pause explanation when there is one, and the commit-identity fields.
|
/// its **switch** picker, and the pause explanation when the surface is held. Branch *creation* and
|
||||||
|
/// the commit-identity fields left with the split — they are setup, and setup's home is the board
|
||||||
|
/// settings sheet (`BoardSettingsSheet`), which the section's Board Settings… row opens.
|
||||||
///
|
///
|
||||||
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push, push-on-commit and the
|
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push and the status badges are
|
||||||
/// authentication surface are 07-sync-collab.md's own cards, and this section is arranged so they
|
/// 07-sync-collab.md's own cards, and this section is arranged so they join as one more block under
|
||||||
/// join as one more block between the branch controls and the identity fields — nothing here is
|
/// the branch controls — nothing here is nested inside anything they would have to be pulled out of,
|
||||||
/// nested inside anything they would have to be pulled out of, and nothing about the branch controls
|
/// and nothing about the branch controls assumes there is no upstream to show beside them.
|
||||||
/// assumes there is no upstream to show beside them.
|
|
||||||
struct BoardGitControls: View {
|
struct BoardGitControls: View {
|
||||||
|
|
||||||
let git: HistoryStore
|
let git: HistoryStore
|
||||||
@@ -79,9 +82,6 @@ struct BoardGitControls: View {
|
|||||||
/// refuses a checkout most of all — it rewrites the tree the lock exists to stop describing.
|
/// refuses a checkout most of all — it rewrites the tree the lock exists to stop describing.
|
||||||
let isEnabled: Bool
|
let isEnabled: Bool
|
||||||
|
|
||||||
@State private var isNaming = false
|
|
||||||
@State private var draftBranch = ""
|
|
||||||
|
|
||||||
private var surface: BoardGitBranchSurface {
|
private var surface: BoardGitBranchSurface {
|
||||||
BoardGitBranchSurface.resolve(
|
BoardGitBranchSurface.resolve(
|
||||||
branch: git.branch,
|
branch: git.branch,
|
||||||
@@ -95,10 +95,6 @@ struct BoardGitControls: View {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
branchRow
|
branchRow
|
||||||
|
|
||||||
if isNaming {
|
|
||||||
newBranchField
|
|
||||||
}
|
|
||||||
|
|
||||||
if let explanation = surface.pauseExplanation {
|
if let explanation = surface.pauseExplanation {
|
||||||
pauseNote(explanation)
|
pauseNote(explanation)
|
||||||
}
|
}
|
||||||
@@ -106,8 +102,6 @@ struct BoardGitControls: View {
|
|||||||
if let failure = git.switcher?.lastFailure {
|
if let failure = git.switcher?.lastFailure {
|
||||||
caption(failure.message, tone: .red)
|
caption(failure.message, tone: .red)
|
||||||
}
|
}
|
||||||
|
|
||||||
BoardGitIdentityFields(git: git, isEnabled: isEnabled)
|
|
||||||
}
|
}
|
||||||
// Every read the section needs, taken when it appears rather than held live: the popover is
|
// Every read the section needs, taken when it appears rather than held live: the popover is
|
||||||
// built fresh on each open (`BoardInfoWidget`), and none of these is a fact the board's
|
// built fresh on each open (`BoardInfoWidget`), and none of these is a fact the board's
|
||||||
@@ -122,8 +116,14 @@ struct BoardGitControls: View {
|
|||||||
// MARK: The branch line
|
// MARK: The branch line
|
||||||
|
|
||||||
/// The branch display and the switch, as one control: the line *is* the picker, which is what
|
/// The branch display and the switch, as one control: the line *is* the picker, which is what
|
||||||
/// makes "branch/source display, branch switching and creation" one affordance rather than a label
|
/// makes "branch/source display and switching" one affordance rather than a label with a button
|
||||||
/// with a button beside it.
|
/// beside it.
|
||||||
|
///
|
||||||
|
/// **Switch entries and nothing else** since the 2026-07-31 split — the "New Branch…" entry that
|
||||||
|
/// used to close the menu is the settings sheet's standing Create field now. On a board with one
|
||||||
|
/// branch the menu is therefore *empty*, and that is left honest rather than papered over: the
|
||||||
|
/// label still reads the current branch and still disables with the rest of the git surface, so
|
||||||
|
/// the line goes on saying the one thing it is here to say.
|
||||||
private var branchRow: some View {
|
private var branchRow: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "arrow.triangle.branch")
|
Image(systemName: "arrow.triangle.branch")
|
||||||
@@ -136,13 +136,6 @@ struct BoardGitControls: View {
|
|||||||
Task { await git.switcher?.switchTo(name) }
|
Task { await git.switcher?.switchTo(name) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !otherBranches.isEmpty {
|
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
Button("New Branch…") {
|
|
||||||
draftBranch = ""
|
|
||||||
isNaming = true
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Text(surface.branchLabel)
|
Text(surface.branchLabel)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@@ -152,7 +145,7 @@ struct BoardGitControls: View {
|
|||||||
.fixedSize()
|
.fixedSize()
|
||||||
.disabled(!surface.controlsEnabled)
|
.disabled(!surface.controlsEnabled)
|
||||||
.accessibilityLabel(surface.accessibilityLabel)
|
.accessibilityLabel(surface.accessibilityLabel)
|
||||||
.accessibilityHint("Switch branches or create a branch")
|
.accessibilityHint("Switch branches")
|
||||||
|
|
||||||
if git.switcher?.isSwitching == true {
|
if git.switcher?.isSwitching == true {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -168,41 +161,6 @@ struct BoardGitControls: View {
|
|||||||
(git.switcher?.branches ?? []).filter { $0 != git.branch }
|
(git.switcher?.branches ?? []).filter { $0 != git.branch }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Create and switch
|
|
||||||
|
|
||||||
/// Named inline rather than in a sheet: the popover is where the operation was asked for, and a
|
|
||||||
/// sheet over a transient popover would dismiss the surface it came from.
|
|
||||||
private var newBranchField: some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
TextField("New branch name", text: $draftBranch)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.lineLimit(1)
|
|
||||||
.onSubmit { create() }
|
|
||||||
// Escape steps outward one layer (04-interactions.md ▸ Grammar): it abandons the
|
|
||||||
// naming rather than dismissing the popover under it.
|
|
||||||
.onKeyPress(.escape) {
|
|
||||||
isNaming = false
|
|
||||||
draftBranch = ""
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
Button("Create", action: create)
|
|
||||||
.disabled(trimmedDraft.isEmpty)
|
|
||||||
}
|
|
||||||
.disabled(!surface.controlsEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var trimmedDraft: String {
|
|
||||||
draftBranch.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func create() {
|
|
||||||
let name = trimmedDraft
|
|
||||||
guard !name.isEmpty else { return }
|
|
||||||
isNaming = false
|
|
||||||
draftBranch = ""
|
|
||||||
Task { await git.switcher?.createAndSwitch(to: name) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: The pause
|
// MARK: The pause
|
||||||
|
|
||||||
/// **The abnormal-state surface** (06 ▸ Rules ▸ Abnormal repo states) — deferred here from the
|
/// **The abnormal-state surface** (06 ▸ Rules ▸ Abnormal repo states) — deferred here from the
|
||||||
@@ -231,134 +189,3 @@ struct BoardGitControls: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Commit identity
|
|
||||||
|
|
||||||
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md ▸ Interaction
|
|
||||||
/// with external writers: "The board settings sheet's identity section … exposes name/email fields
|
|
||||||
/// that write that repo-local config — the setting *is* the file, portable to any git client,
|
|
||||||
/// per-board by nature").
|
|
||||||
///
|
|
||||||
/// **Its home is the sheet, and the sheet is not built yet.** The 2026-07-31 popover/sheet split moved
|
|
||||||
/// every setup-shaped control — add-git, branch creation, these fields, remote and credentials — out
|
|
||||||
/// of the popover and into a board settings sheet (03-board-ui.md ▸ Board settings sheet), leaving the
|
|
||||||
/// popover the daily face. This view is the fields, hosted where they were; the sheet's card moves
|
|
||||||
/// them, along with the visibility-scoped poll below, which 06 says "rides with the fields".
|
|
||||||
///
|
|
||||||
/// ### The placeholder is the whole of the identity rule made visible
|
|
||||||
///
|
|
||||||
/// An empty field shows the **derived default** — the macOS account's full name and
|
|
||||||
/// `shortname@hostname` — as a placeholder, never as a value. That is the difference between "this
|
|
||||||
/// repository says nothing, so the app signs commits with a sensible guess" and "this repository says
|
|
||||||
/// this", and the file is where the difference lives: 06 forbids the app writing its own derived
|
|
||||||
/// value into config, because it would then outrank the user's global `~/.gitconfig` for their own
|
|
||||||
/// terminal commits in that board. A field pre-filled with the derived value would write it on the
|
|
||||||
/// first focus loss.
|
|
||||||
///
|
|
||||||
/// ### The dirty-buffer courtesy, copied from `BoardRenameField`
|
|
||||||
///
|
|
||||||
/// A foreign config edit landing while the popover is open updates an *unfocused* field and never a
|
|
||||||
/// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md ▸ Board popover). The
|
|
||||||
/// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher`
|
|
||||||
/// filters `.git` out of the watch by design, so no board event can ever carry a config change, and
|
|
||||||
/// the alternative to a small periodic read is a field that is stale for as long as the popover
|
|
||||||
/// stays open. The poll lives and dies with this view.
|
|
||||||
private struct BoardGitIdentityFields: View {
|
|
||||||
|
|
||||||
let git: HistoryStore
|
|
||||||
let isEnabled: Bool
|
|
||||||
|
|
||||||
@State private var name = ""
|
|
||||||
@State private var email = ""
|
|
||||||
@FocusState private var focused: Field?
|
|
||||||
|
|
||||||
private enum Field: Hashable {
|
|
||||||
case name
|
|
||||||
case email
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **The fields re-read the config at 2 s while the surface is visible** (06 ▸ Interaction with
|
|
||||||
/// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event
|
|
||||||
/// can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and
|
|
||||||
/// a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused
|
|
||||||
/// field keeps its keystrokes; dismissing the sheet stops the poll)."
|
|
||||||
private static let pollInterval: Duration = .seconds(2)
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Text("Commit Identity")
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name)
|
|
||||||
field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email)
|
|
||||||
|
|
||||||
if let failure = git.identityFailure {
|
|
||||||
Text(failure.message)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
// The first read, then the courtesy poll. Cancellation is the view's disappearance, which
|
|
||||||
// is the popover closing.
|
|
||||||
while !Task.isCancelled {
|
|
||||||
await git.refreshIdentity()
|
|
||||||
try? await Task.sleep(for: Self.pollInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
name = git.identityName
|
|
||||||
email = git.identityEmail
|
|
||||||
}
|
|
||||||
.onChange(of: git.identityName) { _, value in
|
|
||||||
guard focused != .name else { return }
|
|
||||||
name = value
|
|
||||||
}
|
|
||||||
.onChange(of: git.identityEmail) { _, value in
|
|
||||||
guard focused != .email else { return }
|
|
||||||
email = value
|
|
||||||
}
|
|
||||||
// A dismissal is a commit like any other click-away — `BoardRenameField`'s rule, and the same
|
|
||||||
// idempotence makes the overlap harmless.
|
|
||||||
.onDisappear { commit() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func field(
|
|
||||||
_ label: String,
|
|
||||||
text: Binding<String>,
|
|
||||||
placeholder: String,
|
|
||||||
tag: Field
|
|
||||||
) -> some View {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text(label)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(width: 44, alignment: .leading)
|
|
||||||
TextField(placeholder, text: text)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.lineLimit(1)
|
|
||||||
.focused($focused, equals: tag)
|
|
||||||
.onSubmit { commit() }
|
|
||||||
.disabled(!isEnabled)
|
|
||||||
.accessibilityLabel("Commit \(label.lowercased())")
|
|
||||||
}
|
|
||||||
.onChange(of: focused) { previous, _ in
|
|
||||||
// Focus leaving *this* field is this field's commit — the inline editors' exit, applied
|
|
||||||
// to a form where Tab moves between two of them.
|
|
||||||
guard previous == tag else { return }
|
|
||||||
commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes both fields, and only when one of them differs from what the file says — an unchanged
|
|
||||||
/// value must not rewrite `.git/config` every time the popover closes.
|
|
||||||
private func commit() {
|
|
||||||
guard isEnabled else { return }
|
|
||||||
guard name != git.identityName || email != git.identityEmail else { return }
|
|
||||||
Task { await git.writeIdentity(name: name, email: email) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ struct BoardInfoWidget: View {
|
|||||||
|
|
||||||
@Bindable var presentation: BoardInfoPresentation
|
@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.
|
/// 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**
|
/// 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
|
/// 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.
|
// costs nothing on the other four postures.
|
||||||
.task { await git?.refreshBranch() }
|
.task { await git?.refreshBranch() }
|
||||||
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
|
.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
|
/// 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.
|
/// SwiftUI's, and anything hung on it has to be taken back off.
|
||||||
@MainActor
|
@MainActor
|
||||||
/// `tier`/`git` default to the free tier's posture — a popover with no git section at all — so that
|
/// `tier`/`git` default to the free tier's posture — a popover with no git section at all — and
|
||||||
/// a caller with no session in hand (the accessory-installation tests, which are about AppKit
|
/// `settings` to no sheet, so that a caller with no session in hand (the accessory-installation
|
||||||
/// plumbing rather than about git) describes a board honestly rather than by accident. The app's own
|
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
|
||||||
/// call site passes the session's values explicitly.
|
/// than by accident. The app's own call site passes the session's values explicitly.
|
||||||
func boardInfoTitlebarAccessory(
|
func boardInfoTitlebarAccessory(
|
||||||
store: BoardStore,
|
store: BoardStore,
|
||||||
recents: StyleRecents,
|
recents: StyleRecents,
|
||||||
tier: Tier = .free,
|
tier: Tier = .free,
|
||||||
git: HistoryStore? = nil,
|
git: HistoryStore? = nil,
|
||||||
presentation: BoardInfoPresentation
|
presentation: BoardInfoPresentation,
|
||||||
|
settings: BoardSettingsPresentation? = nil
|
||||||
) -> NSTitlebarAccessoryViewController {
|
) -> NSTitlebarAccessoryViewController {
|
||||||
let hosting = NSHostingView(
|
let hosting = NSHostingView(
|
||||||
rootView: BoardInfoWidget(
|
rootView: BoardInfoWidget(
|
||||||
@@ -224,7 +231,8 @@ func boardInfoTitlebarAccessory(
|
|||||||
recents: recents,
|
recents: recents,
|
||||||
tier: tier,
|
tier: tier,
|
||||||
git: git,
|
git: git,
|
||||||
presentation: presentation
|
presentation: presentation,
|
||||||
|
settings: settings
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
|
// 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 recents: StyleRecents
|
||||||
let tier: Tier
|
let tier: Tier
|
||||||
let git: HistoryStore?
|
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
|
/// 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).
|
/// 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)
|
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.store = store
|
||||||
self.recents = recents
|
self.recents = recents
|
||||||
self.tier = tier
|
self.tier = tier
|
||||||
self.git = git
|
self.git = git
|
||||||
|
self.settings = settings
|
||||||
// Asked only where it is the answer: under Pro the mode already knows, and a free-tier
|
// 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`).
|
// 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)
|
self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL)
|
||||||
@@ -331,13 +353,14 @@ struct BoardInfoView: View {
|
|||||||
BoardGitNote()
|
BoardGitNote()
|
||||||
.padding(inset)
|
.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()
|
Divider()
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Git")
|
sectionHeader("Git")
|
||||||
if let git {
|
boardSettingsRow
|
||||||
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(inset)
|
.padding(inset)
|
||||||
|
|
||||||
@@ -356,11 +379,37 @@ struct BoardInfoView: View {
|
|||||||
if let git {
|
if let git {
|
||||||
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
|
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
|
||||||
}
|
}
|
||||||
|
boardSettingsRow
|
||||||
}
|
}
|
||||||
.padding(inset)
|
.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
|
/// The section titles, matching the style editor's own headers so the popover reads as one
|
||||||
/// surface rather than borrowed ones.
|
/// surface rather than borrowed ones.
|
||||||
private func sectionHeader(_ title: String) -> some View {
|
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
|
/// 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
|
/// 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).
|
/// 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 {
|
enum BoardGitSection: Equatable, CaseIterable {
|
||||||
|
|
||||||
/// Nothing at all — the free tier's ordinary board, where "the popover is rename + style,
|
/// 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).
|
/// to Pro (12 ▸ Tier naming).
|
||||||
case proPointer
|
case proPointer
|
||||||
|
|
||||||
/// Pro, mode `none`: the add-git action (06 ▸ Rules ▸ Opt-in init).
|
/// Pro, mode `none`: a board that could have a history and has none. There is no daily surface
|
||||||
case addGit
|
/// 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
|
case repoNested
|
||||||
|
|
||||||
/// Pro, git mode: the branch/source line with switching and creation, the abnormal-state
|
/// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state
|
||||||
/// explanation when the surface is held, and the commit-identity fields (`BoardGitControls`).
|
/// explanation when the surface is held, and the Board Settings… row. The remote half —
|
||||||
/// The remote half — tracking, Pull/Push, push-on-commit, authentication — is 07-sync-collab.md's
|
/// tracking, Pull/Push, the status badges — is 07-sync-collab.md's own card and joins this same
|
||||||
/// own card and joins this same posture.
|
/// posture.
|
||||||
case branch
|
case branch
|
||||||
|
|
||||||
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
|
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
|
||||||
@@ -487,7 +546,7 @@ enum BoardGitSection: Equatable, CaseIterable {
|
|||||||
return hasGitDirectory ? .proPointer : .absent
|
return hasGitDirectory ? .proPointer : .absent
|
||||||
case .pro:
|
case .pro:
|
||||||
switch mode {
|
switch mode {
|
||||||
case .none: return .addGit
|
case .none: return .noRepository
|
||||||
case .git: return .branch
|
case .git: return .branch
|
||||||
case .repoNested: return .repoNested
|
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:
|
/// **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.
|
/// short prose in place of an action, never a hidden or greyed-out add-git.
|
||||||
private struct BoardGitNestedNote: View {
|
private struct BoardGitNestedNote: View {
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// **The board settings sheet** — "the setup home" (03-board-ui.md ▸ Board settings sheet, ruled
|
||||||
|
/// 2026-07-31: the popover/sheet split, 04-interactions.md's configuration carve-out).
|
||||||
|
///
|
||||||
|
/// ### Why there are two configuration surfaces and not one
|
||||||
|
///
|
||||||
|
/// The split's reasons are mechanical, not aesthetic (04 ▸ The map): setup flows fire confirmation
|
||||||
|
/// alerts, run inline network probes, and accept drag-in key import — "acts that need a surface a
|
||||||
|
/// stray click can't dismiss". So the **popover** keeps the daily face (rename, styling, the branch
|
||||||
|
/// display and its switch picker, the posture lines) and this sheet takes everything setup-shaped.
|
||||||
|
/// **Each control has exactly one home**: "the popover never duplicates a sheet control, the sheet
|
||||||
|
/// never hosts the daily surface."
|
||||||
|
///
|
||||||
|
/// ### What is here, and what joins it
|
||||||
|
///
|
||||||
|
/// Three sections ship with the sheet itself, and each of them *moved* here rather than being written
|
||||||
|
/// here: **add-git** (mode `none`), **branch creation** (mode `git`), and the **commit-identity**
|
||||||
|
/// fields (mode `git`). 03's inventory for this surface is longer — add/change remote with its inline
|
||||||
|
/// verify probe, the HTTPS credential fields, the whole SSH surface with its drag-in key import and
|
||||||
|
/// TOFU confirms, push-on-commit — and every one of those is a pro-m2 card that joins as **one more
|
||||||
|
/// section** (`BoardSettingsSection`), which is the only thing the shape here has to promise.
|
||||||
|
///
|
||||||
|
/// ### Undo routing needs nothing from this file
|
||||||
|
///
|
||||||
|
/// "The settings sheet's text fields own ⌘Z/⇧⌘Z as field-local text undo while focused"
|
||||||
|
/// (06-history-undo.md ▸ Undo routing). A SwiftUI sheet is hosted in its own `NSWindow`, so a focused
|
||||||
|
/// field's editor supplies its own undo manager through the responder chain exactly as the popover's
|
||||||
|
/// rename field does — the platform's first-responder rule, working by construction. Nothing here
|
||||||
|
/// wires it, and nothing here may quietly take it away: a board-level ⌘Z reaching a typo would be
|
||||||
|
/// 06's "reflexive undo over a typo must never become a tree checkout".
|
||||||
|
|
||||||
|
// MARK: - Presentation state
|
||||||
|
|
||||||
|
/// Whether **this window's** settings sheet is open, and the session posture that decides whether it
|
||||||
|
/// may open at all.
|
||||||
|
///
|
||||||
|
/// `BoardInfoPresentation`'s sibling in every respect (see it for why the flag is per *window* rather
|
||||||
|
/// than per board or per app): one per window, `@State` in `BoardWindowHost`, published through the
|
||||||
|
/// focus system so Board ▸ Board Settings… means "the board in front".
|
||||||
|
///
|
||||||
|
/// **It carries the session's tier and git state** where the popover flag carries nothing, and for a
|
||||||
|
/// reason the popover does not have: both of this sheet's doors have to *validate*, and one of them
|
||||||
|
/// is a menu row with no view around it to ask. The facts are adopted once, from the session, at the
|
||||||
|
/// same moment the titlebar widget adopts them (`BoardWindowHost.configureWindow`) and never
|
||||||
|
/// re-derived — 12-editions.md ▸ The entitlement, "a lapse never interrupts an open session". The
|
||||||
|
/// *mode* inside the git state is `@Observable` and does move, by add-git alone, which is exactly the
|
||||||
|
/// transition this sheet is where the user performs: the sections re-resolve under it live.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class BoardSettingsPresentation {
|
||||||
|
|
||||||
|
var isPresented = false
|
||||||
|
|
||||||
|
/// The tier this window's board composed under — the free tier's default until a session says
|
||||||
|
/// otherwise, which is the harmless direction (an unreachable sheet).
|
||||||
|
private(set) var tier: Tier = .free
|
||||||
|
|
||||||
|
/// This window's board git state, `nil` under the free tier and on a window whose session has not
|
||||||
|
/// been adopted yet.
|
||||||
|
private(set) var git: HistoryStore?
|
||||||
|
|
||||||
|
/// Called once per window, from the same place the titlebar widget is handed the same two facts.
|
||||||
|
func adopt(tier: Tier, git: HistoryStore?) {
|
||||||
|
self.tier = tier
|
||||||
|
self.git = git
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the sheet would show right now — and therefore, when empty, that there is no sheet to
|
||||||
|
/// show (`BoardSettingsAvailability`).
|
||||||
|
var sections: [BoardSettingsSection] {
|
||||||
|
BoardSettingsSection.resolve(tier: tier, mode: git?.mode ?? .none)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both doors' validation: the menu row's `disabled` state and whether the popover shows its row
|
||||||
|
/// at all.
|
||||||
|
var isReachable: Bool {
|
||||||
|
BoardSettingsAvailability.resolve(tier: tier, mode: git?.mode ?? .none)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Opening, not toggling** — unlike ⌘I. A sheet is modal to its window and carries its own
|
||||||
|
/// dismissal (Done, and Escape through it), so a command that could also *close* it would be a
|
||||||
|
/// second exit for a surface that already has the platform's; and neither door is reachable while
|
||||||
|
/// the sheet is up anyway (the menu is behind it, the popover is dismissed by it).
|
||||||
|
///
|
||||||
|
/// The guard is not defensive dressing: both doors validate on `isReachable` already, and a
|
||||||
|
/// third path that forgot to would present a sheet with no sections in it.
|
||||||
|
func present() {
|
||||||
|
guard isReachable else { return }
|
||||||
|
isPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismiss() {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The focused board window's settings sheet, beside `FocusedValues.boardInfo` — see
|
||||||
|
/// `FocusedBoardStoreKey` for why board-window menu items reach their window this way.
|
||||||
|
struct FocusedBoardSettingsKey: FocusedValueKey {
|
||||||
|
typealias Value = BoardSettingsPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FocusedValues {
|
||||||
|
var boardSettings: BoardSettingsPresentation? {
|
||||||
|
get { self[FocusedBoardSettingsKey.self] }
|
||||||
|
set { self[FocusedBoardSettingsKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - What the sheet holds
|
||||||
|
|
||||||
|
/// **The sheet's inventory for one board**, as a pure function of the tier and the mode — the shape
|
||||||
|
/// `BoardGitSection.resolve` has one surface over, and for the same reason: the *contents* are the
|
||||||
|
/// part worth pinning and the SwiftUI that renders them is not.
|
||||||
|
///
|
||||||
|
/// Ordered as the sheet lays them out, top to bottom. pro-m2's cards each add a case here and a
|
||||||
|
/// branch in `BoardSettingsSheet.section(_:)` — nothing else.
|
||||||
|
enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
|
||||||
|
|
||||||
|
/// Pro, mode `none`: **add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init).
|
||||||
|
case git
|
||||||
|
|
||||||
|
/// Pro, mode `git`: **branch creation**. Switching stays in the popover (03 ▸ Board popover);
|
||||||
|
/// create-and-switch runs 06's identical settle sequence from here.
|
||||||
|
case branch
|
||||||
|
|
||||||
|
/// Pro, mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
|
||||||
|
/// (06 ▸ Interaction with external writers).
|
||||||
|
case commitIdentity
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// The section header — a heading VoiceOver navigates by (10-accessibility.md ▸ Board settings
|
||||||
|
/// sheet: "titled and sectioned with headers VoiceOver can navigate by").
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .git: "Git"
|
||||||
|
case .branch: "Branch"
|
||||||
|
case .commitIdentity: "Commit Identity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(tier: Tier, mode: BoardGitMode) -> [BoardSettingsSection] {
|
||||||
|
// **The free tier has no setup to host** (12-editions.md ▸ The free tier and `.git`): git is
|
||||||
|
// the Pro subscription's, "any `.git` is inert", and 03 gives the free tier's whole git story
|
||||||
|
// as the popover's one-line pointer. There is nothing for a sheet to be about.
|
||||||
|
guard tier == .pro else { return [] }
|
||||||
|
switch mode {
|
||||||
|
case .none:
|
||||||
|
return [.git]
|
||||||
|
case .git:
|
||||||
|
return [.branch, .commitIdentity]
|
||||||
|
case .repoNested:
|
||||||
|
// **Nothing setup-shaped can apply** (06 ▸ Rules): the board lives inside a repository
|
||||||
|
// Lanework leaves alone, so there is no add-git (the design is insistent that the option
|
||||||
|
// is *absent*, "prose, not a disabled button"), no branch of ours to create, and no
|
||||||
|
// repo-local config of ours to write. An empty sheet would be the greyed-out button one
|
||||||
|
// level up — so the popover's prose stands and this surface simply does not exist for
|
||||||
|
// such a board.
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Whether the sheet is reachable at all**, for both of its doors — Board ▸ Board Settings…'s
|
||||||
|
/// `disabled` state and whether the popover renders its Board Settings… row.
|
||||||
|
///
|
||||||
|
/// Reachable **iff the sheet has something to show**, which is the rule rather than a shortcut: a
|
||||||
|
/// surface whose whole job is hosting setup controls has no honest empty state, and deriving the
|
||||||
|
/// answer from the inventory is what keeps the two from drifting when pro-m2's sections land. In
|
||||||
|
/// today's terms that reads: Pro, on a board whose mode is `none` or `git`.
|
||||||
|
///
|
||||||
|
/// The two unreachable postures are unreachable for *different* reasons, and both are the design's:
|
||||||
|
///
|
||||||
|
/// - **The free tier**: no setup exists there at all (03-board-ui.md ▸ Board popover;
|
||||||
|
/// 12-editions.md). The popover's own postures are untouched by this card — an ordinary board
|
||||||
|
/// shows nothing, a board carrying an inert `.git` shows the one-line Pro pointer.
|
||||||
|
/// - **Pro, repo-nested**: `BoardSettingsSection.resolve`'s own comment carries this one — nothing
|
||||||
|
/// setup-shaped can apply, so the popover's prose stands and no door opens.
|
||||||
|
///
|
||||||
|
/// The menu **row stays visible and disabled** either way (standard menu validation — a command that
|
||||||
|
/// does not apply here is still a command this app has), while the **popover row appears only where
|
||||||
|
/// the sheet is reachable**: a menu is an inventory of the app, a popover section is a description of
|
||||||
|
/// this board.
|
||||||
|
enum BoardSettingsAvailability {
|
||||||
|
|
||||||
|
static func resolve(tier: Tier, mode: BoardGitMode) -> Bool {
|
||||||
|
!BoardSettingsSection.resolve(tier: tier, mode: mode).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The sheet
|
||||||
|
|
||||||
|
/// The sheet itself: a header naming it and the board, the sections, and one Done.
|
||||||
|
struct BoardSettingsSheet: View {
|
||||||
|
|
||||||
|
let store: BoardStore
|
||||||
|
let presentation: BoardSettingsPresentation
|
||||||
|
|
||||||
|
private var bodyPointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||||
|
|
||||||
|
private var width: CGFloat {
|
||||||
|
BoardSettingsSheetLayout.width(bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inset: CGFloat {
|
||||||
|
BoardSettingsSheetLayout.inset(bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// No scroll container, deliberately: three sections fit any screen at any text size, and
|
||||||
|
// a `ScrollView` inside a content-sized sheet has to be given a height — a decision worth
|
||||||
|
// making when pro-m2's credential and SSH sections make it real, not before.
|
||||||
|
VStack(alignment: .leading, spacing: inset) {
|
||||||
|
ForEach(presentation.sections) { section in
|
||||||
|
self.section(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(inset)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.frame(width: width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The chrome
|
||||||
|
|
||||||
|
/// **Titled** (10-accessibility.md ▸ Board settings sheet) — the surface's own name, plus the
|
||||||
|
/// board's so a user with two boards open knows which one this is about.
|
||||||
|
///
|
||||||
|
/// The board's name is `AppModel.displayName(of:)` — the title-falls-back-to-the-folder-name rule
|
||||||
|
/// (01-storage-format.md ▸ Board naming), called rather than restated: `BoardInfoTitlebarSummary`
|
||||||
|
/// restates it only because it must answer without a `BoardStore`, and this sheet has one.
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Board Settings")
|
||||||
|
.font(.headline)
|
||||||
|
.accessibilityAddTraits(.isHeader)
|
||||||
|
Text(AppModel.displayName(of: store))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(inset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The one exit, twice** — the button and Escape.
|
||||||
|
///
|
||||||
|
/// `.cancelAction` is what wires Escape to it, and the naming is deliberate rather than sloppy:
|
||||||
|
/// nothing on this sheet is staged, so there is nothing a Cancel could roll back — every control
|
||||||
|
/// writes when it is used, and the button says Done because that is what dismissing means here.
|
||||||
|
/// Not `.defaultAction`, so Return stays the focused field's (the branch name field submits with
|
||||||
|
/// it).
|
||||||
|
private var footer: some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Done") {
|
||||||
|
presentation.dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
}
|
||||||
|
.padding(inset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The sections
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func section(_ section: BoardSettingsSection) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(section.title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
// What "sectioned" buys a VoiceOver user: headings the rotor jumps between, rather
|
||||||
|
// than one flat run of controls (10 ▸ Board settings sheet — "the sheet exists partly
|
||||||
|
// *because* Tab-walking two dozen controls in an untitled popover failed this bar").
|
||||||
|
.accessibilityAddTraits(.isHeader)
|
||||||
|
|
||||||
|
if let git = presentation.git {
|
||||||
|
switch section {
|
||||||
|
case .git:
|
||||||
|
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
|
||||||
|
case .branch:
|
||||||
|
BoardSettingsBranchSection(git: git, isEnabled: store.acceptsBoardMutations)
|
||||||
|
case .commitIdentity:
|
||||||
|
BoardGitIdentityFields(git: git, isEnabled: store.acceptsBoardMutations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Geometry
|
||||||
|
|
||||||
|
/// The sheet's two figures, **derived from the body font** like every other surface's
|
||||||
|
/// (10-accessibility.md ▸ Text scaling: "relative text styles everywhere, no fixed point sizes"), and
|
||||||
|
/// stated here rather than inline so they are one decision.
|
||||||
|
///
|
||||||
|
/// A sheet is a window the app sizes, so a width it does not choose is a width AppKit derives from
|
||||||
|
/// whatever the widest control happened to be — which would move every time a section joined. The
|
||||||
|
/// figure is wide enough for a labeled two-column form (the identity fields) and narrower than the
|
||||||
|
/// board window's own floor, so the sheet reads as a card on the window rather than as a second one.
|
||||||
|
enum BoardSettingsSheetLayout {
|
||||||
|
|
||||||
|
/// 30 em: 390pt at the standard 13pt body.
|
||||||
|
static func width(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
BoardMetrics.em(30, bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sheet's inset **and** the gap between two sections — one figure, because a section's
|
||||||
|
/// distance from its neighbour and from the sheet's edge are the same rhythm. 1.55 em: 20pt at
|
||||||
|
/// the standard body.
|
||||||
|
static func inset(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
BoardMetrics.em(1.55, bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity form's label column. 3.4 em: 44pt at the standard body, which is what those
|
||||||
|
/// fields have always drawn.
|
||||||
|
static func labelColumn(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
BoardMetrics.em(3.4, bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Add git
|
||||||
|
|
||||||
|
/// **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.
|
||||||
|
///
|
||||||
|
/// **It moved here from the popover with the 2026-07-31 split** (03-board-ui.md ▸ Board settings
|
||||||
|
/// sheet: the sheet hosts "add-git (mode none; opt-in init — 06)"), carrying the two lines below with
|
||||||
|
/// it.
|
||||||
|
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.
|
||||||
|
/// The sheet **stays open** under the lock and disables in place, which is the style popover's
|
||||||
|
/// settled precedent (03 ▸ Board settings sheet).
|
||||||
|
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"): inline while this sheet is up, the
|
||||||
|
// banner once it is gone. 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.
|
||||||
|
//
|
||||||
|
// The section's visibility *is* the sheet's here, and stays so as pro-m2's sections arrive:
|
||||||
|
// add-git exists only on mode `none`, and a successful one flips the mode — which is the one
|
||||||
|
// disappearance that is not a dismissal, and it is the right one (the failure slot empties
|
||||||
|
// because there is nothing left to fail).
|
||||||
|
.onAppear { git.noteFormVisible(true) }
|
||||||
|
.onDisappear { git.noteFormVisible(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Branch creation
|
||||||
|
|
||||||
|
/// **Branch creation** (06-history-undo.md ▸ Branch switching; 03-board-ui.md ▸ Board settings sheet:
|
||||||
|
/// "branch creation (switching stays in the popover; create-and-switch runs 06's identical settle
|
||||||
|
/// sequence from here)").
|
||||||
|
///
|
||||||
|
/// ### A standing field, not a reveal
|
||||||
|
///
|
||||||
|
/// In the popover this was a "New Branch…" entry inside the switch menu that revealed an inline field
|
||||||
|
/// — the right shape *there*, where the surface is a compact daily face and the field was a detour off
|
||||||
|
/// it. A form is a form: this sheet exists to hold setup controls standing, so the field stands, and
|
||||||
|
/// the reveal dance retires with the container that motivated it. The Create button validates on a
|
||||||
|
/// non-empty trimmed name, which is the only thing the dance was ever gating.
|
||||||
|
///
|
||||||
|
/// ### The sequence is not this view's
|
||||||
|
///
|
||||||
|
/// `GitBranchSwitcher.createAndSwitch(to:)` runs the identical settle → flush → stamp → switch
|
||||||
|
/// sequence the picker's switch does — "no at-HEAD fast path" (06, blessed 2026-07-31) — and this
|
||||||
|
/// view calls exactly that method. The save-or-discard step it may raise is an **alert over the
|
||||||
|
/// sheet**, which is one of the mechanical reasons the sheet exists at all: "confirmation alerts
|
||||||
|
/// present over the sheet without dismissing the flow that owns them" (03).
|
||||||
|
private struct BoardSettingsBranchSection: View {
|
||||||
|
|
||||||
|
let git: HistoryStore
|
||||||
|
let isEnabled: Bool
|
||||||
|
|
||||||
|
@State private var draft = ""
|
||||||
|
|
||||||
|
/// The same four facts the popover's branch line reads, so a paused repository, a read-only board
|
||||||
|
/// and a switch in flight close this control exactly as they close that one — one rule, one
|
||||||
|
/// derivation (`BoardGitBranchSurface`).
|
||||||
|
private var surface: BoardGitBranchSurface {
|
||||||
|
BoardGitBranchSurface.resolve(
|
||||||
|
branch: git.branch,
|
||||||
|
pause: git.committer?.pause,
|
||||||
|
isSwitching: git.switcher?.isSwitching ?? false,
|
||||||
|
isWritable: isEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
TextField("New branch name", text: $draft)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1)
|
||||||
|
.onSubmit { create() }
|
||||||
|
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar),
|
||||||
|
// the rename field's rule: a dirty field abandons its draft and keeps the sheet
|
||||||
|
// up; an empty one lets the press through to the sheet's own dismissal.
|
||||||
|
.onKeyPress(.escape) {
|
||||||
|
guard !draft.isEmpty else { return .ignored }
|
||||||
|
draft = ""
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
.accessibilityLabel("New branch name")
|
||||||
|
|
||||||
|
Button("Create", action: create)
|
||||||
|
.disabled(trimmedDraft.isEmpty)
|
||||||
|
|
||||||
|
if git.switcher?.isSwitching == true {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.accessibilityLabel("Switching branches")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!surface.controlsEnabled)
|
||||||
|
|
||||||
|
Text("Creates the branch from the current one and switches to it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
// The switcher's last failure, said where it was asked for. The popover's own caption
|
||||||
|
// stays (a switch asked *there* answers there); the two can never show at once, since
|
||||||
|
// opening this sheet dismisses that popover.
|
||||||
|
if let failure = git.switcher?.lastFailure {
|
||||||
|
Text(failure.message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The two reads `surface` needs that nothing else on this sheet takes — the branch name and
|
||||||
|
// the pause — asked when the sheet appears, because neither is a fact the board's watcher
|
||||||
|
// could deliver (`.git` is filtered out of the watch by design). The branch *list* is not
|
||||||
|
// read here: this section creates, and only the popover's picker needs to know what exists.
|
||||||
|
.task {
|
||||||
|
await git.refreshBranch()
|
||||||
|
await git.committer?.refreshPause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trimmedDraft: String {
|
||||||
|
draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func create() {
|
||||||
|
let name = trimmedDraft
|
||||||
|
guard !name.isEmpty, surface.controlsEnabled else { return }
|
||||||
|
draft = ""
|
||||||
|
Task { await git.switcher?.createAndSwitch(to: name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Commit identity
|
||||||
|
|
||||||
|
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md ▸ Interaction
|
||||||
|
/// with external writers: "The board settings sheet's identity section … exposes name/email fields
|
||||||
|
/// that write that repo-local config — the setting *is* the file, portable to any git client,
|
||||||
|
/// per-board by nature").
|
||||||
|
///
|
||||||
|
/// It moved here whole from the popover with the 2026-07-31 split, poll included — 06 says the
|
||||||
|
/// visibility-scoped re-read "rides with the fields", so hosting the view here *is* the re-point:
|
||||||
|
/// the `.task` below now lives and dies with the sheet.
|
||||||
|
///
|
||||||
|
/// ### The placeholder is the whole of the identity rule made visible
|
||||||
|
///
|
||||||
|
/// An empty field shows the **derived default** — the macOS account's full name and
|
||||||
|
/// `shortname@hostname` — as a placeholder, never as a value. That is the difference between "this
|
||||||
|
/// repository says nothing, so the app signs commits with a sensible guess" and "this repository says
|
||||||
|
/// this", and the file is where the difference lives: 06 forbids the app writing its own derived
|
||||||
|
/// value into config, because it would then outrank the user's global `~/.gitconfig` for their own
|
||||||
|
/// terminal commits in that board. A field pre-filled with the derived value would write it on the
|
||||||
|
/// first focus loss.
|
||||||
|
///
|
||||||
|
/// ### The dirty-buffer courtesy, copied from `BoardRenameField`
|
||||||
|
///
|
||||||
|
/// A foreign config edit landing while the sheet is open updates an *unfocused* field and never a
|
||||||
|
/// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md ▸ Board popover). The
|
||||||
|
/// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher`
|
||||||
|
/// filters `.git` out of the watch by design, so no board event can ever carry a config change, and
|
||||||
|
/// the alternative to a small periodic read is a field that is stale for as long as the sheet stays
|
||||||
|
/// open.
|
||||||
|
private struct BoardGitIdentityFields: View {
|
||||||
|
|
||||||
|
let git: HistoryStore
|
||||||
|
let isEnabled: Bool
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var email = ""
|
||||||
|
@FocusState private var focused: Field?
|
||||||
|
|
||||||
|
private enum Field: Hashable {
|
||||||
|
case name
|
||||||
|
case email
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The fields re-read the config at 2 s while the sheet is visible** (06 ▸ Interaction with
|
||||||
|
/// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event
|
||||||
|
/// can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and
|
||||||
|
/// a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused
|
||||||
|
/// field keeps its keystrokes; dismissing the sheet stops the poll)."
|
||||||
|
private static let pollInterval: Duration = .seconds(2)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name)
|
||||||
|
field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email)
|
||||||
|
|
||||||
|
if let failure = git.identityFailure {
|
||||||
|
Text(failure.message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
// The first read, then the courtesy poll. Cancellation is the view's disappearance, which
|
||||||
|
// is the sheet closing.
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await git.refreshIdentity()
|
||||||
|
try? await Task.sleep(for: Self.pollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
name = git.identityName
|
||||||
|
email = git.identityEmail
|
||||||
|
}
|
||||||
|
.onChange(of: git.identityName) { _, value in
|
||||||
|
guard focused != .name else { return }
|
||||||
|
name = value
|
||||||
|
}
|
||||||
|
.onChange(of: git.identityEmail) { _, value in
|
||||||
|
guard focused != .email else { return }
|
||||||
|
email = value
|
||||||
|
}
|
||||||
|
// A dismissal is a commit like any other click-away — `BoardRenameField`'s rule, and the same
|
||||||
|
// idempotence makes the overlap harmless.
|
||||||
|
.onDisappear { commit() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func field(
|
||||||
|
_ label: String,
|
||||||
|
text: Binding<String>,
|
||||||
|
placeholder: String,
|
||||||
|
tag: Field
|
||||||
|
) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(
|
||||||
|
width: BoardSettingsSheetLayout.labelColumn(bodyPointSize: BoardMetrics.bodyPointSize),
|
||||||
|
alignment: .leading
|
||||||
|
)
|
||||||
|
TextField(placeholder, text: text)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1)
|
||||||
|
.focused($focused, equals: tag)
|
||||||
|
.onSubmit { commit() }
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.accessibilityLabel("Commit \(label.lowercased())")
|
||||||
|
}
|
||||||
|
.onChange(of: focused) { previous, _ in
|
||||||
|
// Focus leaving *this* field is this field's commit — the inline editors' exit, applied
|
||||||
|
// to a form where Tab moves between two of them.
|
||||||
|
guard previous == tag else { return }
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes both fields, and only when one of them differs from what the file says — an unchanged
|
||||||
|
/// value must not rewrite `.git/config` every time the sheet closes.
|
||||||
|
private func commit() {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
guard name != git.identityName || email != git.identityEmail else { return }
|
||||||
|
Task { await git.writeIdentity(name: name, email: email) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,21 +47,23 @@ struct BoardGitSectionTests {
|
|||||||
#expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: true) == .proPointer)
|
#expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: true) == .proPointer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Pro: mode none offers add-git, git mode shows the branch")
|
@Test("Pro: mode none has no repository to describe, git mode shows the branch")
|
||||||
func proFollowsTheMode() {
|
func proFollowsTheMode() {
|
||||||
#expect(BoardGitSection.resolve(tier: .pro, mode: .none, hasGitDirectory: false) == .addGit)
|
#expect(BoardGitSection.resolve(tier: .pro, mode: .none, hasGitDirectory: false) == .noRepository)
|
||||||
#expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch)
|
#expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A repo-nested board explains itself — the add-git action is absent, not disabled")
|
@Test("A repo-nested board explains itself — setup is absent, not disabled")
|
||||||
func repoNestedExplainsRatherThanDisables() {
|
func repoNestedExplainsRatherThanDisables() {
|
||||||
let section = BoardGitSection.resolve(tier: .pro, mode: .repoNested, hasGitDirectory: false)
|
let section = BoardGitSection.resolve(tier: .pro, mode: .repoNested, hasGitDirectory: false)
|
||||||
|
|
||||||
// The design is insistent here: "not a hidden 'add git' but a short explanation … the option
|
// The design is insistent here: "not a hidden 'add git' but a short explanation … the option
|
||||||
// is absent because it *can't* apply, and the UI should teach that rather than look broken"
|
// is absent because it *can't* apply, and the UI should teach that rather than look broken"
|
||||||
// (06 ▸ Rules). A `.addGit` that rendered disabled would satisfy neither half.
|
// (06 ▸ Rules). A posture that rendered a disabled setup control would satisfy neither half —
|
||||||
|
// and since the 2026-07-31 split the same sentence is what keeps the settings sheet itself
|
||||||
|
// out of reach on such a board (`BoardSettingsAvailabilityTests`).
|
||||||
#expect(section == .repoNested)
|
#expect(section == .repoNested)
|
||||||
#expect(section != .addGit)
|
#expect(section != .noRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Every posture is reachable, and none of them is two postures")
|
@Test("Every posture is reachable, and none of them is two postures")
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// **The board settings sheet's two pure seams** (03-board-ui.md ▸ Board settings sheet, ruled
|
||||||
|
/// 2026-07-31 — the popover/sheet split; 04-interactions.md ▸ The map's configuration carve-out):
|
||||||
|
/// what the sheet holds for a board, and therefore whether the sheet exists for that board at all.
|
||||||
|
///
|
||||||
|
/// They are pinned here for `BoardGitSection.resolve`'s reason one surface over: the *inventory* is
|
||||||
|
/// the decision worth asserting and the SwiftUI that renders it is not. Every case below is a plain
|
||||||
|
/// value — no board on disk, no window, no session.
|
||||||
|
@Suite("Board settings sheet ▸ the sections")
|
||||||
|
struct BoardSettingsSectionTests {
|
||||||
|
|
||||||
|
@Test("Pro, mode none: the sheet is add-git and nothing else")
|
||||||
|
func modeNoneHoldsAddGit() {
|
||||||
|
// "add-git (mode none; opt-in init — 06)" — 03's own first entry for this surface, and the
|
||||||
|
// control that moved here out of the popover with the split.
|
||||||
|
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .none) == [.git])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Pro, git mode: branch creation and the commit identity, in that order")
|
||||||
|
func gitModeHoldsCreationAndIdentity() {
|
||||||
|
// "branch creation (switching stays in the popover…)" and "commit identity name/email (06 —
|
||||||
|
// the visibility-scoped 2 s config re-read rides with the fields)".
|
||||||
|
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every section is reachable from some posture, and no posture invents one")
|
||||||
|
func theInventoryIsTotal() {
|
||||||
|
let offered = Set(
|
||||||
|
Tier.allCases.flatMap { tier in
|
||||||
|
BoardGitMode.allCases.flatMap { BoardSettingsSection.resolve(tier: tier, mode: $0) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(offered == Set(BoardSettingsSection.allCases))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The sections carry the headers VoiceOver navigates by")
|
||||||
|
func headersAreNamed() {
|
||||||
|
// 10-accessibility.md ▸ Board settings sheet: "titled and sectioned with headers VoiceOver
|
||||||
|
// can navigate by". The strings are the surface's spoken structure, so they are stated once
|
||||||
|
// and pinned once.
|
||||||
|
#expect(BoardSettingsSection.git.title == "Git")
|
||||||
|
#expect(BoardSettingsSection.branch.title == "Branch")
|
||||||
|
#expect(BoardSettingsSection.commitIdentity.title == "Commit Identity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Where the sheet can be opened from, tier by mode** — the answer both doors validate on: Board ▸
|
||||||
|
/// Board Settings…'s `disabled` state, and whether the popover's git section renders its Board
|
||||||
|
/// Settings… row at all.
|
||||||
|
@Suite("Board settings sheet ▸ availability")
|
||||||
|
struct BoardSettingsAvailabilityTests {
|
||||||
|
|
||||||
|
@Test("The whole matrix: Pro on a none-or-git board, and nowhere else")
|
||||||
|
func theMatrix() {
|
||||||
|
// Pro, and a board whose mode leaves something to set up.
|
||||||
|
#expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .none))
|
||||||
|
#expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .git))
|
||||||
|
|
||||||
|
// **Pro, repo-nested**: "nothing setup-shaped can apply" — no add-git (06's prose, not a
|
||||||
|
// disabled button), no branch of ours to create, no repo-local config of ours to write. The
|
||||||
|
// popover's explanation stands and no door opens.
|
||||||
|
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .repoNested))
|
||||||
|
|
||||||
|
// **The free tier**: no setup exists there at all (12-editions.md ▸ The free tier and
|
||||||
|
// `.git`), whatever mode a stray value claims — detection never runs off Pro, so the mode is
|
||||||
|
// swept for completeness rather than because it can vary.
|
||||||
|
for mode in BoardGitMode.allCases {
|
||||||
|
#expect(
|
||||||
|
!BoardSettingsAvailability.resolve(tier: .free, mode: mode),
|
||||||
|
"the free tier has no board settings sheet in any mode"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Reachable means exactly 'has something to show'")
|
||||||
|
func reachabilityIsTheInventory() {
|
||||||
|
// The derivation, not a coincidence: a surface whose whole job is hosting setup controls has
|
||||||
|
// no honest empty state, so the two answers are one answer. pro-m2's sections join the
|
||||||
|
// inventory and this identity keeps holding.
|
||||||
|
for tier in Tier.allCases {
|
||||||
|
for mode in BoardGitMode.allCases {
|
||||||
|
#expect(
|
||||||
|
BoardSettingsAvailability.resolve(tier: tier, mode: mode)
|
||||||
|
== !BoardSettingsSection.resolve(tier: tier, mode: mode).isEmpty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The window's sheet flag** (`BoardSettingsPresentation`) — `BoardInfoPresentation`'s sibling,
|
||||||
|
/// with the one thing the popover flag does not carry: the session posture both doors validate on.
|
||||||
|
@MainActor
|
||||||
|
@Suite("Board settings sheet ▸ the window's presentation")
|
||||||
|
struct BoardSettingsPresentationTests {
|
||||||
|
|
||||||
|
@Test("It starts closed, unadopted, and unreachable")
|
||||||
|
func startsClosed() {
|
||||||
|
let presentation = BoardSettingsPresentation()
|
||||||
|
|
||||||
|
#expect(presentation.isPresented == false)
|
||||||
|
#expect(presentation.tier == .free)
|
||||||
|
#expect(presentation.git == nil)
|
||||||
|
// The free tier's posture is the safe default for a window whose session has not been adopted
|
||||||
|
// yet: an unreachable sheet, rather than a sheet with no sections in it.
|
||||||
|
#expect(presentation.isReachable == false)
|
||||||
|
#expect(presentation.sections.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An unreachable board's sheet refuses to present")
|
||||||
|
func presentRefusesWhereUnreachable() {
|
||||||
|
let presentation = BoardSettingsPresentation()
|
||||||
|
|
||||||
|
presentation.present()
|
||||||
|
#expect(presentation.isPresented == false, "the free tier has no sheet to open")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Two windows hold their own flags")
|
||||||
|
func perWindow() {
|
||||||
|
// `BoardInfoPresentation`'s rule, restated for the sheet: "two board windows each hold their
|
||||||
|
// own and can never toggle each other's".
|
||||||
|
let first = BoardSettingsPresentation()
|
||||||
|
let second = BoardSettingsPresentation()
|
||||||
|
|
||||||
|
first.isPresented = true
|
||||||
|
#expect(second.isPresented == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Adopting a Pro git session makes the sheet reachable, and dismissal is idempotent")
|
||||||
|
func adoptingASession() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
|
||||||
|
// Mode `none` — the add-git posture, which is the sheet's whole job on a board with no
|
||||||
|
// repository yet.
|
||||||
|
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||||
|
#expect(git.mode == .none)
|
||||||
|
|
||||||
|
let presentation = BoardSettingsPresentation()
|
||||||
|
presentation.adopt(tier: .pro, git: git)
|
||||||
|
|
||||||
|
#expect(presentation.isReachable)
|
||||||
|
#expect(presentation.sections == [.git])
|
||||||
|
|
||||||
|
presentation.present()
|
||||||
|
#expect(presentation.isPresented)
|
||||||
|
|
||||||
|
presentation.dismiss()
|
||||||
|
presentation.dismiss()
|
||||||
|
#expect(presentation.isPresented == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The free tier's session leaves both doors shut, .git on the folder or not")
|
||||||
|
func aFreeTierSessionIsUnreachable() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
|
||||||
|
|
||||||
|
// `compose` is the tier gate: the free tier gets no git state at all, so there is nothing for
|
||||||
|
// a settings sheet to be about even on a board carrying an inert `.git` — the popover's
|
||||||
|
// one-line Pro pointer is that board's whole story (12-editions.md).
|
||||||
|
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
|
||||||
|
|
||||||
|
let presentation = BoardSettingsPresentation()
|
||||||
|
presentation.adopt(tier: .free, git: nil)
|
||||||
|
|
||||||
|
#expect(presentation.isReachable == false)
|
||||||
|
presentation.present()
|
||||||
|
#expect(presentation.isPresented == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Kanban
|
@testable import Kanban
|
||||||
|
|
||||||
/// `caretChordsYield(boardInfo:search:)` — 04-interactions.md ▸ Grammar's caret-chords rule as one
|
/// `caretChordsYield(boardInfo:boardSettings:search:)` — 04-interactions.md ▸ Grammar's caret-chords
|
||||||
/// expression, and `BoardCommands.swift`'s single seam for it: Board ▸ Move Left/Move Right ⌘←/⌘→ and
|
/// rule as one expression, and `BoardCommands.swift`'s single seam for it: Board ▸ Move Left/Move
|
||||||
/// the lane-width pair ⌥⌘←/⌥⌘→ disable via menu validation whenever *any* text control has keyboard
|
/// Right ⌘←/⌘→ and the lane-width pair ⌥⌘←/⌥⌘→ disable via menu validation whenever *any* text
|
||||||
/// focus, because ⌘←/⌘→ are the standard line-start/end caret chords and an enabled key equivalent
|
/// control has keyboard focus, because ⌘←/⌘→ are the standard line-start/end caret chords and an
|
||||||
/// fires before a field ever sees the key.
|
/// enabled key equivalent fires before a field ever sees the key.
|
||||||
///
|
///
|
||||||
/// The function reads exactly two flags and nothing else, so every test here constructs
|
/// The function reads exactly three flags and nothing else, so every test here constructs
|
||||||
/// `BoardInfoPresentation` and `BoardSearchPresentation` directly rather than through a `BoardStore` —
|
/// `BoardInfoPresentation`, `BoardSettingsPresentation` and `BoardSearchPresentation` directly rather
|
||||||
/// a fixture that stood up a board would be exercising machinery this seam never touches.
|
/// than through a `BoardStore` — a fixture that stood up a board would be exercising machinery this
|
||||||
|
/// seam never touches.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("caretChordsYield ▸ the caret-chords rule")
|
@Suite("caretChordsYield ▸ the caret-chords rule")
|
||||||
struct CaretChordTests {
|
struct CaretChordTests {
|
||||||
@@ -21,7 +22,7 @@ struct CaretChordTests {
|
|||||||
// "Card-window fields need nothing: those windows never publish a boardStore, so both items
|
// "Card-window fields need nothing: those windows never publish a boardStore, so both items
|
||||||
// are already scopeless there" (caretChordsYield's doc comment) — nil/nil is that window's
|
// are already scopeless there" (caretChordsYield's doc comment) — nil/nil is that window's
|
||||||
// steady state, not a corner case.
|
// steady state, not a corner case.
|
||||||
#expect(caretChordsYield(boardInfo: nil, search: nil) == false)
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: nil) == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: The board popover, alone
|
// MARK: The board popover, alone
|
||||||
@@ -29,13 +30,46 @@ struct CaretChordTests {
|
|||||||
@Test("The board popover open yields; closed, it does not")
|
@Test("The board popover open yields; closed, it does not")
|
||||||
func popoverPresence() {
|
func popoverPresence() {
|
||||||
let boardInfo = BoardInfoPresentation()
|
let boardInfo = BoardInfoPresentation()
|
||||||
#expect(caretChordsYield(boardInfo: boardInfo, search: nil) == false, "closed by default")
|
#expect(caretChordsYield(boardInfo: boardInfo, boardSettings: nil, search: nil) == false, "closed by default")
|
||||||
|
|
||||||
boardInfo.isPresented = true
|
boardInfo.isPresented = true
|
||||||
#expect(caretChordsYield(boardInfo: boardInfo, search: nil) == true)
|
#expect(caretChordsYield(boardInfo: boardInfo, boardSettings: nil, search: nil) == true)
|
||||||
|
|
||||||
boardInfo.isPresented = false
|
boardInfo.isPresented = false
|
||||||
#expect(caretChordsYield(boardInfo: boardInfo, search: nil) == false, "closing re-enables the chords")
|
#expect(caretChordsYield(boardInfo: boardInfo, boardSettings: nil, search: nil) == false, "closing re-enables the chords")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The settings sheet, alone
|
||||||
|
|
||||||
|
@Test("The settings sheet open yields; closed, it does not")
|
||||||
|
func settingsSheetPresence() {
|
||||||
|
// The sheet joined this seam with the 2026-07-31 popover/sheet split: it is where the branch
|
||||||
|
// name field and the commit-identity fields live now, so the surface that used to be covered
|
||||||
|
// by "the popover is open" has to be covered by "the sheet is up" as well.
|
||||||
|
let settings = BoardSettingsPresentation()
|
||||||
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: settings, search: nil) == false, "closed by default")
|
||||||
|
|
||||||
|
settings.isPresented = true
|
||||||
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: settings, search: nil) == true)
|
||||||
|
|
||||||
|
settings.isPresented = false
|
||||||
|
#expect(
|
||||||
|
caretChordsYield(boardInfo: nil, boardSettings: settings, search: nil) == false,
|
||||||
|
"dismissing re-enables the chords"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The sheet's own reachability is not this seam's question — a presented sheet yields either way")
|
||||||
|
func presentationRatherThanReachability() {
|
||||||
|
// `isReachable` gates the two *doors* (`BoardSettingsAvailability`); this function reads the
|
||||||
|
// flag that says a sheet is on screen. A presentation left unadopted reads free-tier and
|
||||||
|
// therefore unreachable, and if something ever put its flag up anyway the chords must still
|
||||||
|
// yield — what a caret chord competes with is a field that exists, not a tier.
|
||||||
|
let settings = BoardSettingsPresentation()
|
||||||
|
#expect(settings.isReachable == false, "unadopted reads as the free tier")
|
||||||
|
|
||||||
|
settings.isPresented = true
|
||||||
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: settings, search: nil) == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: The search field, alone
|
// MARK: The search field, alone
|
||||||
@@ -43,13 +77,13 @@ struct CaretChordTests {
|
|||||||
@Test("The search field focused yields; unfocused, it does not")
|
@Test("The search field focused yields; unfocused, it does not")
|
||||||
func searchFocus() {
|
func searchFocus() {
|
||||||
let search = BoardSearchPresentation()
|
let search = BoardSearchPresentation()
|
||||||
#expect(caretChordsYield(boardInfo: nil, search: search) == false, "unfocused by default")
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: search) == false, "unfocused by default")
|
||||||
|
|
||||||
search.isFocused = true
|
search.isFocused = true
|
||||||
#expect(caretChordsYield(boardInfo: nil, search: search) == true)
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: search) == true)
|
||||||
|
|
||||||
search.isFocused = false
|
search.isFocused = false
|
||||||
#expect(caretChordsYield(boardInfo: nil, search: search) == false, "losing focus re-enables the chords")
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: search) == false, "losing focus re-enables the chords")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Both surfaces together
|
// MARK: Both surfaces together
|
||||||
@@ -64,22 +98,32 @@ struct CaretChordTests {
|
|||||||
focusedSearch.isFocused = true
|
focusedSearch.isFocused = true
|
||||||
|
|
||||||
// One side published and inert, the other absent (the still-loading-window shape): false.
|
// One side published and inert, the other absent (the still-loading-window shape): false.
|
||||||
#expect(caretChordsYield(boardInfo: closedInfo, search: nil) == false)
|
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: nil, search: nil) == false)
|
||||||
#expect(caretChordsYield(boardInfo: nil, search: unfocusedSearch) == false)
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: unfocusedSearch) == false)
|
||||||
|
|
||||||
// One side published and active, the other absent: true.
|
// One side published and active, the other absent: true.
|
||||||
#expect(caretChordsYield(boardInfo: openInfo, search: nil) == true)
|
#expect(caretChordsYield(boardInfo: openInfo, boardSettings: nil, search: nil) == true)
|
||||||
#expect(caretChordsYield(boardInfo: nil, search: focusedSearch) == true)
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: focusedSearch) == true)
|
||||||
|
|
||||||
// Both published, both inert: false — the popover being open at all and the field holding
|
// Both published, both inert: false — the popover being open at all and the field holding
|
||||||
// focus are each read independently, so neither's mere presence counts on its own.
|
// focus are each read independently, so neither's mere presence counts on its own.
|
||||||
#expect(caretChordsYield(boardInfo: closedInfo, search: unfocusedSearch) == false)
|
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: nil, search: unfocusedSearch) == false)
|
||||||
|
|
||||||
// Both published, one or both active: true. This is an `||`, not an `&&` — one live text
|
// Both published, one or both active: true. This is an `||`, not an `&&` — one live text
|
||||||
// surface is enough to send the chords to it, whatever the other surface is doing.
|
// surface is enough to send the chords to it, whatever the other surface is doing.
|
||||||
#expect(caretChordsYield(boardInfo: openInfo, search: unfocusedSearch) == true)
|
#expect(caretChordsYield(boardInfo: openInfo, boardSettings: nil, search: unfocusedSearch) == true)
|
||||||
#expect(caretChordsYield(boardInfo: closedInfo, search: focusedSearch) == true)
|
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: nil, search: focusedSearch) == true)
|
||||||
#expect(caretChordsYield(boardInfo: openInfo, search: focusedSearch) == true)
|
#expect(caretChordsYield(boardInfo: openInfo, boardSettings: nil, search: focusedSearch) == true)
|
||||||
|
|
||||||
|
// And the third surface composes the same way — inert beside two inert siblings, sufficient
|
||||||
|
// beside them when it is up.
|
||||||
|
let closedSheet = BoardSettingsPresentation()
|
||||||
|
let openSheet = BoardSettingsPresentation()
|
||||||
|
openSheet.isPresented = true
|
||||||
|
|
||||||
|
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: closedSheet, search: unfocusedSearch) == false)
|
||||||
|
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: openSheet, search: unfocusedSearch) == true)
|
||||||
|
#expect(caretChordsYield(boardInfo: openInfo, boardSettings: openSheet, search: focusedSearch) == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Inline editors are a different seam
|
// MARK: Inline editors are a different seam
|
||||||
@@ -98,6 +142,6 @@ struct CaretChordTests {
|
|||||||
|
|
||||||
// No board popover, no search focus — caretChordsYield answers false regardless of the open
|
// No board popover, no search focus — caretChordsYield answers false regardless of the open
|
||||||
// editor above, because it never reads isEditingInline at all.
|
// editor above, because it never reads isEditingInline at all.
|
||||||
#expect(caretChordsYield(boardInfo: nil, search: nil) == false)
|
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: nil) == false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,24 @@ import XCTest
|
|||||||
///
|
///
|
||||||
/// > **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`)
|
/// > **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`)
|
||||||
/// > runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit,
|
/// > runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit,
|
||||||
/// > raw source), welcome, template chooser, board popover.
|
/// > raw source), welcome, template chooser, board popover, board settings sheet.
|
||||||
///
|
///
|
||||||
/// Eight surfaces, eight tests, one audit call each. `performAccessibilityAudit` audits **the app's
|
/// One test per surface, one audit call each. `performAccessibilityAudit` audits **the app's
|
||||||
/// currently displayed UI** rather than a subtree, so each test's job is entirely navigation: get the
|
/// currently displayed UI** rather than a subtree, so each test's job is entirely navigation: get the
|
||||||
/// surface on screen, then let the audit look at whatever is there.
|
/// surface on screen, then let the audit look at whatever is there.
|
||||||
///
|
///
|
||||||
|
/// ### The one surface this suite cannot reach, and why it is not fixable here
|
||||||
|
///
|
||||||
|
/// The **board settings sheet** is Pro-only (03-board-ui.md ▸ Board settings sheet;
|
||||||
|
/// `BoardSettingsAvailability`), and the fixture launch has no tier control: `AppModel.currentTier`
|
||||||
|
/// reads `ProEntitlement`, which reads StoreKit, and there is deliberately **no launch argument that
|
||||||
|
/// grants Pro**. Adding one would be worse than the gap it closed — `UITestLaunch` is compiled into
|
||||||
|
/// the shipping binary on purpose ("the thing being audited must be the app that ships"), so a
|
||||||
|
/// `--ui-test-pro` flag would be a subscription bypass anyone could type into Terminal. So the sheet's
|
||||||
|
/// own audit is **deferred to the manual VoiceOver pass** (`AccessibilityVerification.md`), and what
|
||||||
|
/// is automated here instead is the posture that *is* reachable: the free tier's, where the row exists
|
||||||
|
/// and is disabled. See `testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture`.
|
||||||
|
///
|
||||||
/// ### No waiving
|
/// ### No waiving
|
||||||
///
|
///
|
||||||
/// The audit's issue handler is where a false positive would be excused, and every test here passes
|
/// The audit's issue handler is where a false positive would be excused, and every test here passes
|
||||||
@@ -188,4 +200,46 @@ final class AccessibilityAuditTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
try app.performAccessibilityAudit()
|
try app.performAccessibilityAudit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The board settings sheet
|
||||||
|
|
||||||
|
/// **The reachable half of the settings sheet's audit** — the free tier's, which is the tier the
|
||||||
|
/// fixture launch runs in (see this file's header for why there is no Pro fixture and why adding
|
||||||
|
/// one would be a subscription bypass).
|
||||||
|
///
|
||||||
|
/// What it pins is the design's deliberate asymmetry between the sheet's two doors
|
||||||
|
/// (`BoardSettingsAvailability`): the **menu row stays visible and disabled** where the sheet
|
||||||
|
/// cannot exist — standard menu validation, and 11-command-nexus.md's "a command absent here
|
||||||
|
/// doesn't exist" read in the other direction — while the popover's row is *absent* there instead.
|
||||||
|
/// A row that vanished from the menu on the free tier would be the app hiding its own inventory;
|
||||||
|
/// one that was enabled would present an empty sheet.
|
||||||
|
///
|
||||||
|
/// The audit call is the board's, taken with the Board menu closed again: the sheet is what this
|
||||||
|
/// test cannot open, so auditing the surface that *is* on screen keeps the test honest about what
|
||||||
|
/// it checked.
|
||||||
|
@MainActor
|
||||||
|
func testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture() throws {
|
||||||
|
let app = XCUIApplication.launchedWithFixtureBoard()
|
||||||
|
|
||||||
|
let bar = app.menuBars.firstMatch
|
||||||
|
let boardMenu = bar.menuBarItems["Board"]
|
||||||
|
XCTAssertTrue(
|
||||||
|
boardMenu.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"the Board menu is missing from the menu bar"
|
||||||
|
)
|
||||||
|
boardMenu.click()
|
||||||
|
|
||||||
|
let row = bar.menuItems["Board Settings…"]
|
||||||
|
XCTAssertTrue(
|
||||||
|
row.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||||
|
"Board ▸ Board Settings… is missing — the row ships whether or not this board can open it"
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
row.isEnabled,
|
||||||
|
"the free tier has no board settings sheet, so the row must be disabled rather than open an empty one"
|
||||||
|
)
|
||||||
|
app.typeKey(.escape, modifierFlags: [])
|
||||||
|
|
||||||
|
try app.performAccessibilityAudit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Have a scratch board to hand for Part 2 — a new one from File ▸ New Board…
|
|||||||
|
|
||||||
## Part 1 — run the audit suite
|
## Part 1 — run the audit suite
|
||||||
|
|
||||||
`KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all nine surfaces the design names. **Violations are test failures, not warnings**, and nothing is waived: the audits pass no issue handler at all.
|
`KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over the surfaces the design names — nine of the ten automatically, the tenth (the **board settings sheet**) by hand in Part 3, because it is Pro-only and the fixture launch has no tier control. **Violations are test failures, not warnings**, and nothing is waived: the audits pass no issue handler at all.
|
||||||
|
|
||||||
```
|
```
|
||||||
xcodebuild test -project Kanban.xcodeproj -scheme Kanban \
|
xcodebuild test -project Kanban.xcodeproj -scheme Kanban \
|
||||||
@@ -51,11 +51,23 @@ The nine surfaces, and how each test gets there:
|
|||||||
| `testWelcomeWindow` | Welcome, with a recents row | Window ▸ Welcome to Lanework |
|
| `testWelcomeWindow` | Welcome, with a recents row | Window ▸ Welcome to Lanework |
|
||||||
| `testTemplateChooser` | Template chooser | File ▸ New Board… |
|
| `testTemplateChooser` | Template chooser | File ▸ New Board… |
|
||||||
| `testBoardInfoPopover` | Board popover | File ▸ Board Info |
|
| `testBoardInfoPopover` | Board popover | File ▸ Board Info |
|
||||||
|
| `testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture` | Board window, with Board ▸ Board Settings… checked | Opens the Board menu, asserts the row is present and disabled, closes it |
|
||||||
|
|
||||||
Every test launches the app with `--ui-test-fixture-board`, which makes the app build a known board inside its own container and open it — three lanes ("To Do", "Doing", "Done"), six cards, one card with a rich Markdown body, an attachment and a three-comment thread (one unattributed, one edited), one card already in the trash. That is the `standard` fixture variant; the bare flag means it, and the other two shapes (`large`, `malformed`) belong to the end-to-end pass. The board and the registry both live in a scratch directory that is wiped on every launch, so an audit run never touches your real boards or your recents list. See `Kanban/App/UITestLaunch.swift` for why the board cannot simply be handed to the app on the command line (the sandbox).
|
Every test launches the app with `--ui-test-fixture-board`, which makes the app build a known board inside its own container and open it — three lanes ("To Do", "Doing", "Done"), six cards, one card with a rich Markdown body, an attachment and a three-comment thread (one unattributed, one edited), one card already in the trash. That is the `standard` fixture variant; the bare flag means it, and the other two shapes (`large`, `malformed`) belong to the end-to-end pass. The board and the registry both live in a scratch directory that is wiped on every launch, so an audit run never touches your real boards or your recents list. See `Kanban/App/UITestLaunch.swift` for why the board cannot simply be handed to the app on the command line (the sandbox).
|
||||||
|
|
||||||
If a test fails, read the issue's `compactDescription` and fix the app. Adding a waiver is a design change and needs an entry on the Redesign board first.
|
If a test fails, read the issue's `compactDescription` and fix the app. Adding a waiver is a design change and needs an entry on the Redesign board first.
|
||||||
|
|
||||||
|
### The board settings sheet, by hand
|
||||||
|
|
||||||
|
The sheet (03-board-ui.md ▸ Board settings sheet) needs **Pro on a board with app-managed git**, and there is deliberately no launch argument that grants Pro: `UITestLaunch` is compiled into the shipping binary, so a tier flag would be a subscription bypass anyone could type into Terminal. Until a fixture can reach Pro honestly, run this by hand once per release on a Pro build, with a git-mode board open:
|
||||||
|
|
||||||
|
- [ ] **Open both doors.** Board ▸ Board Settings…, and the popover's **Board Settings…** row (File ▸ Board Info ▸ Git). The popover dismisses as the sheet appears — never both at once.
|
||||||
|
- [ ] **Sectioned, and navigable by heading.** With VoiceOver on, the rotor's heading list holds "Board Settings" and each section's title ("Branch", "Commit Identity"; "Git" on a board with no repository yet).
|
||||||
|
- [ ] **Tab reaches every control** with VoiceOver off and Full Keyboard Access on — the branch name field, Create, both identity fields, Done.
|
||||||
|
- [ ] **⌘Z inside a field is the field's**, not the board's: type into Commit Identity ▸ Name, press ⌘Z, and the *typing* reverts — no tree checkout, no board step consumed (06-history-undo.md ▸ Undo routing).
|
||||||
|
- [ ] **Escape and Done both dismiss**; Escape in a dirty branch-name field clears the field first (one layer per press).
|
||||||
|
- [ ] **The read-only lock disables in place**: the sheet stays open and its controls grey out, with the banner naming why.
|
||||||
|
|
||||||
## Part 2 — the VoiceOver smoke script
|
## Part 2 — the VoiceOver smoke script
|
||||||
|
|
||||||
Nine steps, in one sitting, on a scratch board. Expected speech is quoted from `Kanban/UI/AccessibilityPhrases.swift`; where VoiceOver adds its own words (role names, "selected", "button") they are shown in [brackets].
|
Nine steps, in one sitting, on a scratch board. Expected speech is quoted from `Kanban/UI/AccessibilityPhrases.swift`; where VoiceOver adds its own words (role names, "selected", "button") they are shown in [brackets].
|
||||||
|
|||||||
Reference in New Issue
Block a user