From 8345378972973edd12ec048ba8d7fe0f6ce14e2a Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 1 Aug 2026 12:07:12 -0400 Subject: [PATCH] =?UTF-8?q?The=20board=20settings=20sheet=20=E2=80=94=20se?= =?UTF-8?q?tup=20leaves=20the=20popover=20for=20a=20home=20a=20stray=20cli?= =?UTF-8?q?ck=20can't=20dismiss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/BoardWindowHost.swift | 28 +- Kanban/Git/HistoryStore.swift | 48 +- Kanban/KanbanApp.swift | 17 +- Kanban/UI/Board/BoardCommands.swift | 67 ++- Kanban/UI/Board/BoardGitControls.swift | 215 +------ Kanban/UI/Board/BoardInfoPopover.swift | 142 +++-- Kanban/UI/Board/BoardSettingsSheet.swift | 619 ++++++++++++++++++++ KanbanTests/BoardInfoPopoverTests.swift | 12 +- KanbanTests/BoardSettingsSheetTests.swift | 177 ++++++ KanbanTests/CaretChordTests.swift | 92 ++- KanbanUITests/AccessibilityAuditTests.swift | 58 +- KanbanUITests/AccessibilityVerification.md | 14 +- 12 files changed, 1168 insertions(+), 321 deletions(-) create mode 100644 Kanban/UI/Board/BoardSettingsSheet.swift create mode 100644 KanbanTests/BoardSettingsSheetTests.swift diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 118737d..87f1344 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -57,6 +57,12 @@ struct BoardWindowHost: View { /// menu item through the focus system, like the store. @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 /// 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. @@ -197,6 +203,17 @@ struct BoardWindowHost: View { .onChange(of: boardSearch.isFocused) { _, _ in 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 // 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 @@ -207,6 +224,7 @@ struct BoardWindowHost: View { // is keyed on the window rather than on the board it is showing. .focusedSceneValue(\.boardWindowRef, ref) .focusedSceneValue(\.boardInfo, boardInfo) + .focusedSceneValue(\.boardSettings, boardSettings) .focusedSceneValue(\.trashConfirmations, trashConfirmations) // 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. @@ -694,13 +712,21 @@ struct BoardWindowHost: View { // happen on this path — `beginSession` precedes `configureWindow` — and reads as the free // tier's posture, which is the harmless direction. 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( boardInfoTitlebarAccessory( store: store, recents: appModel.styleRecents, tier: session?.tier ?? .free, 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) diff --git a/Kanban/Git/HistoryStore.swift b/Kanban/Git/HistoryStore.swift index 9ac5a8f..52be1b7 100644 --- a/Kanban/Git/HistoryStore.swift +++ b/Kanban/Git/HistoryStore.swift @@ -70,9 +70,10 @@ public final class HistoryStore { /// 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. /// - /// The form is the popover's git section today and the board settings sheet once that exists — - /// the ruling's container moved in the 2026-07-31 popover/sheet split, its substance did not, and - /// `noteFormVisible(_:)` is the one line the sheet will re-point. + /// The form is the **board settings sheet's Git section** (`BoardSettingsSheet`), which is where + /// the 2026-07-31 popover/sheet split put add-git; it was the popover's git section until that + /// 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? /// 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 - /// **What repo-local `.git/config` says right now** — the popover's two fields, as values rather - /// than as a resolved identity (06 ▸ Interaction with external writers: "The board popover's git - /// section exposes name/email fields that write that repo-local config — the setting *is* the - /// file"). + /// **What repo-local `.git/config` says right now** — the settings sheet's two fields, as values + /// rather than as a resolved identity (06 ▸ 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"). /// /// 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 /// 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 identityEmail = "" @@ -132,12 +133,12 @@ public final class HistoryStore { /// Deliberately not computed at composition: `GitIdentity.derivedDefault()` reads /// `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 - /// 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? - /// The last identity-write failure, surfaced as an inline caption in the popover beside the fields - /// — 06's popover-anchored posture ("the user asked from a form still under their eye"), which is - /// exactly where `lastFailure` above already puts add-git's. + /// The last identity-write failure, surfaced as an inline caption on the settings sheet beside the + /// fields — 06's form-anchored posture ("the user asked from a form still under their eye"), which + /// is exactly where `lastFailure` above already puts add-git's. public private(set) var identityFailure: GitOperationFailure? /// 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 /// 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 - /// built fresh on each open and the settings sheet is modal to its board window. + /// A `Bool` rather than a count because there is one such form per board at a time: the settings + /// sheet is modal to its board window, and opening it dismisses the popover. public func noteFormVisible(_ visible: Bool) { isFormVisible = visible 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 /// 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 /// under every board it opened. /// /// **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 - /// popover flows straight into the git controls". 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. + /// popover flows straight into the git controls". Since the 2026-07-31 split that flow is one + /// 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 /// one the app "leaves strictly alone" — no nested repo, ever. @@ -279,7 +284,7 @@ public final class HistoryStore { let root = boardRoot // 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) { GitRepository.create(at: root) }.value @@ -326,7 +331,8 @@ public final class HistoryStore { } /// 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 { guard mode == .git else { return } let root = boardRoot @@ -337,8 +343,8 @@ public final class HistoryStore { // MARK: - Commit identity - /// Reads repo-local config and the derived default into the popover's fields. A no-op outside git - /// mode, `refreshBranch()`'s rule. + /// Reads repo-local config and the derived default into the settings sheet's fields. A no-op + /// outside git mode, `refreshBranch()`'s rule. /// /// Both reads run off the main actor: one opens a repository, the other asks the system for the /// account and host names. diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 9ac2b84..d602c1a 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -252,10 +252,15 @@ struct KanbanApp: App { } // 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. - // Its items act on the frontmost board window, which they reach through the focus system - // rather than through the app model — see `BoardCommands.swift`, which also owns their - // validation. + // Card, Rename, Style…, the card moves, the lane moves, the width pair, the remote pair, then + // Board Settings…. Its items act on the frontmost board window, which they reach through the + // focus system rather than through the app model — see `BoardCommands.swift`, which also owns + // 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") { OpenCardCommand() BoardRenameCommand() @@ -273,6 +278,10 @@ struct KanbanApp: App { Divider() RemoteCommands() + + Divider() + + BoardSettingsCommand() } CommandGroup(after: .windowList) { diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index b91b7ef..b8b4ead 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -87,13 +87,17 @@ extension BoardStore { /// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the /// > 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 /// broader lockdown that already covers these two items. /// - **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 /// 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 /// 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. @@ -103,8 +107,14 @@ extension BoardStore { /// 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. @MainActor -func caretChordsYield(boardInfo: BoardInfoPresentation?, search: BoardSearchPresentation?) -> Bool { - boardInfo?.isPresented == true || search?.isFocused == true +func caretChordsYield( + boardInfo: BoardInfoPresentation?, + boardSettings: BoardSettingsPresentation?, + search: BoardSearchPresentation? +) -> Bool { + boardInfo?.isPresented == true + || boardSettings?.isPresented == true + || search?.isFocused == true } // MARK: - Open Card @@ -276,11 +286,13 @@ struct MoveCardCommands: View { /// **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 /// 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 { @FocusedValue(\.boardStore) private var store @FocusedValue(\.boardInfo) private var boardInfo + @FocusedValue(\.boardSettings) private var boardSettings @FocusedValue(\.boardSearch) private var search var body: some View { @@ -298,7 +310,7 @@ struct MoveLaneCommands: View { } 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 @@ -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 /// 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(\.boardInfo) private var boardInfo + @FocusedValue(\.boardSettings) private var boardSettings @FocusedValue(\.boardSearch) private var search var body: some View { @@ -508,7 +563,7 @@ struct LaneWidthCommands: View { } 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. diff --git a/Kanban/UI/Board/BoardGitControls.swift b/Kanban/UI/Board/BoardGitControls.swift index 4d8e915..6723a89 100644 --- a/Kanban/UI/Board/BoardGitControls.swift +++ b/Kanban/UI/Board/BoardGitControls.swift @@ -27,7 +27,9 @@ struct BoardGitBranchSurface: Equatable { /// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live. 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 /// The line the branch display is read as by VoiceOver. @@ -61,16 +63,17 @@ struct BoardGitBranchSurface: Equatable { // MARK: - The git-mode section /// **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 -/// creation, the pause explanation when there is one, and the commit-identity fields. +/// **The daily face, and only that** (the 2026-07-31 popover/sheet split): the branch display with +/// 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 -/// authentication surface are 07-sync-collab.md's own cards, and this section is arranged so they -/// join as one more block between the branch controls and the identity fields — nothing here is -/// nested inside anything they would have to be pulled out of, and nothing about the branch controls -/// assumes there is no upstream to show beside them. +/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push and the status badges are +/// 07-sync-collab.md's own cards, and this section is arranged so they join as one more block under +/// the branch controls — nothing here is nested inside anything they would have to be pulled out of, +/// and nothing about the branch controls assumes there is no upstream to show beside them. struct BoardGitControls: View { 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. let isEnabled: Bool - @State private var isNaming = false - @State private var draftBranch = "" - private var surface: BoardGitBranchSurface { BoardGitBranchSurface.resolve( branch: git.branch, @@ -95,10 +95,6 @@ struct BoardGitControls: View { VStack(alignment: .leading, spacing: 8) { branchRow - if isNaming { - newBranchField - } - if let explanation = surface.pauseExplanation { pauseNote(explanation) } @@ -106,8 +102,6 @@ struct BoardGitControls: View { if let failure = git.switcher?.lastFailure { 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 // 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 /// 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 - /// with a button beside it. + /// makes "branch/source display and switching" one affordance rather than a label with a button + /// 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 { HStack(spacing: 6) { Image(systemName: "arrow.triangle.branch") @@ -136,13 +136,6 @@ struct BoardGitControls: View { Task { await git.switcher?.switchTo(name) } } } - if !otherBranches.isEmpty { - Divider() - } - Button("New Branch…") { - draftBranch = "" - isNaming = true - } } label: { Text(surface.branchLabel) .font(.callout) @@ -152,7 +145,7 @@ struct BoardGitControls: View { .fixedSize() .disabled(!surface.controlsEnabled) .accessibilityLabel(surface.accessibilityLabel) - .accessibilityHint("Switch branches or create a branch") + .accessibilityHint("Switch branches") if git.switcher?.isSwitching == true { ProgressView() @@ -168,41 +161,6 @@ struct BoardGitControls: View { (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 /// **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) } } - -// 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, - 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) } - } -} diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index f8e84f6..d1c90a0 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -84,6 +84,12 @@ struct BoardInfoWidget: View { @Bindable var presentation: BoardInfoPresentation + /// The window's settings sheet, so the popover's git section can carry the **Board Settings…** + /// row that opens it (03-board-ui.md ▸ Board popover: "A Board Settings… row opens the sheet — + /// the popover's one setup affordance"). `nil` where there is no window to present a sheet on, + /// which is the accessory-installation tests' shape and reads as a popover with no row. + let settings: BoardSettingsPresentation? + /// The widget's two strings, computed fresh on every body evaluation rather than cached anywhere. /// That matters here specifically: `boardInfoTitlebarAccessory` builds this view exactly **once** /// at install, so a value read anywhere but inside `body` would freeze at the widget's birth and @@ -149,7 +155,7 @@ struct BoardInfoWidget: View { // costs nothing on the other four postures. .task { await git?.refreshBranch() } .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) { - BoardInfoView(store: store, recents: recents, tier: tier, git: git) + BoardInfoView(store: store, recents: recents, tier: tier, git: git, settings: settings) } } @@ -207,16 +213,17 @@ struct BoardInfoTitlebarSummary: Equatable { /// window, removed on detach — for the same reason it owns the delegate proxying: the window is /// SwiftUI's, and anything hung on it has to be taken back off. @MainActor -/// `tier`/`git` default to the free tier's posture — a popover with no git section at all — so that -/// a caller with no session in hand (the accessory-installation tests, which are about AppKit -/// plumbing rather than about git) describes a board honestly rather than by accident. The app's own -/// call site passes the session's values explicitly. +/// `tier`/`git` default to the free tier's posture — a popover with no git section at all — and +/// `settings` to no sheet, so that a caller with no session in hand (the accessory-installation +/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather +/// than by accident. The app's own call site passes the session's values explicitly. func boardInfoTitlebarAccessory( store: BoardStore, recents: StyleRecents, tier: Tier = .free, git: HistoryStore? = nil, - presentation: BoardInfoPresentation + presentation: BoardInfoPresentation, + settings: BoardSettingsPresentation? = nil ) -> NSTitlebarAccessoryViewController { let hosting = NSHostingView( rootView: BoardInfoWidget( @@ -224,7 +231,8 @@ func boardInfoTitlebarAccessory( recents: recents, tier: tier, git: git, - presentation: presentation + presentation: presentation, + settings: settings ) ) // The titlebar lays its accessories out by fitting size, and a hosting view that measured itself @@ -256,6 +264,13 @@ struct BoardInfoView: View { let recents: StyleRecents let tier: Tier let git: HistoryStore? + let settings: BoardSettingsPresentation? + + /// **The popover's own dismissal**, used by exactly one control: the Board Settings… row, whose + /// job is to close this surface and open the sheet. The popover is presented by `isPresented`, so + /// the environment action drives the same flag the widget's button does — nothing here has to be + /// handed the widget's binding to put it down. + @Environment(\.dismiss) private var dismiss /// Whether this board carries a `.git` — checked once, off disk, when the view is built (which /// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance). @@ -274,11 +289,18 @@ struct BoardInfoView: View { StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize) } - init(store: BoardStore, recents: StyleRecents, tier: Tier = .free, git: HistoryStore? = nil) { + init( + store: BoardStore, + recents: StyleRecents, + tier: Tier = .free, + git: HistoryStore? = nil, + settings: BoardSettingsPresentation? = nil + ) { self.store = store self.recents = recents self.tier = tier self.git = git + self.settings = settings // Asked only where it is the answer: under Pro the mode already knows, and a free-tier // board is the only one this question is for (12-editions.md ▸ The free tier and `.git`). self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL) @@ -331,13 +353,14 @@ struct BoardInfoView: View { BoardGitNote() .padding(inset) - case .addGit: + case .noRepository: + // Nothing daily to show on a board with no repository — so the section is the door and + // its header. Add-git itself moved to the sheet with the 2026-07-31 split; what stays + // here is the honest signpost that this board *could* have a history and where to say so. Divider() VStack(alignment: .leading, spacing: 6) { sectionHeader("Git") - if let git { - BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations) - } + boardSettingsRow } .padding(inset) @@ -356,11 +379,37 @@ struct BoardInfoView: View { if let git { BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations) } + boardSettingsRow } .padding(inset) } } + /// **The popover's one setup affordance** (03-board-ui.md ▸ Board popover) — the sheet's first + /// door, the menu row being the second (11-command-nexus.md). + /// + /// **Shown only where the sheet is reachable** (`BoardSettingsAvailability`): a popover section + /// describes *this board*, so a row pointing at a surface this board cannot have would be the + /// disabled button 06 rules out one level up. The menu row is the opposite case and stays visible + /// — a menu is an inventory of the app. + /// + /// **Dismiss first, then present.** The popover is transient and the sheet is not; leaving a + /// transient surface hanging over a modal one would read as two surfaces arguing about which the + /// user is in. + /// + /// Not disabled by the read-only lock: opening a configuration surface is not a mutation, and the + /// controls inside it disable themselves (the Board Info ⌘I rule). + @ViewBuilder + private var boardSettingsRow: some View { + if let settings, BoardSettingsAvailability.resolve(tier: tier, mode: git?.mode ?? .none) { + Button("Board Settings…") { + dismiss() + settings.present() + } + .accessibilityHint("Opens the board settings sheet") + } + } + /// The section titles, matching the style editor's own headers so the popover reads as one /// surface rather than borrowed ones. private func sectionHeader(_ title: String) -> some View { @@ -456,6 +505,13 @@ private struct BoardRenameField: View { /// mode, one to one — and the mode-`none` and repo-nested pair is where the design is most /// insistent: a repo-nested board gets **prose, not a disabled button**. "The option is absent /// because it *can't* apply, and the UI should teach that rather than look broken" (06 ▸ Rules). +/// +/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup +/// left the popover for the board settings sheet, so mode `none` no longer renders an action here at +/// all (the case was called `.addGit` when it did — a name that would now be describing a control +/// that lives in another file, so it is `.noRepository`), and the git-mode case lost branch creation +/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its +/// test survived the move unchanged. enum BoardGitSection: Equatable, CaseIterable { /// Nothing at all — the free tier's ordinary board, where "the popover is rename + style, @@ -466,16 +522,19 @@ enum BoardGitSection: Equatable, CaseIterable { /// to Pro (12 ▸ Tier naming). case proPointer - /// Pro, mode `none`: the add-git action (06 ▸ Rules ▸ Opt-in init). - case addGit + /// Pro, mode `none`: a board that could have a history and has none. There is no daily surface + /// for that — the section is the header and the Board Settings… row, where add-git now lives + /// (03 ▸ Board settings sheet). + case noRepository - /// Pro, repo-nested: the honest explanation, no action. + /// Pro, repo-nested: the honest explanation, no action — and no Board Settings… row either, + /// since nothing setup-shaped can apply (`BoardSettingsAvailability`). case repoNested - /// Pro, git mode: the branch/source line with switching and creation, the abnormal-state - /// explanation when the surface is held, and the commit-identity fields (`BoardGitControls`). - /// The remote half — tracking, Pull/Push, push-on-commit, authentication — is 07-sync-collab.md's - /// own card and joins this same posture. + /// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state + /// explanation when the surface is held, and the Board Settings… row. The remote half — + /// tracking, Pull/Push, the status badges — is 07-sync-collab.md's own card and joins this same + /// posture. case branch static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection { @@ -487,7 +546,7 @@ enum BoardGitSection: Equatable, CaseIterable { return hasGitDirectory ? .proPointer : .absent case .pro: switch mode { - case .none: return .addGit + case .none: return .noRepository case .git: return .branch case .repoNested: return .repoNested } @@ -495,49 +554,6 @@ enum BoardGitSection: Equatable, CaseIterable { } } -/// **The add-git action** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the one place in the app that -/// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than -/// a promise: there is no other caller of `HistoryStore.addGit`. -/// -/// The caption states what pressing it does, in the order it happens, because it is not undoable in -/// the ordinary sense: a repository appears in the board's folder and its current state becomes the -/// first commit. -private struct BoardGitAddAction: View { - - let git: HistoryStore - /// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses - /// writes refuses this one too — initializing a repository is a write, and a commit is several. - let isEnabled: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Button("Add Git") { - Task { await git.addGit() } - } - .disabled(!isEnabled || git.isAddingGit) - - Text("Creates a git repository in this board's folder and commits its current state.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - if let failure = git.lastFailure { - Text(failure.message) - .font(.caption) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - // **The form add-git answers at** (06 ▸ Interaction with external writers, ruled 2026-07-31 — - // "Form-anchored operations answer at the form first"; its container moved to the board - // settings sheet in the 2026-07-31 split, and these two lines are what that sheet re-points). - // Appearing claims the inline surface; disappearing gives it up, which both dismisses the - // stale error and sends any answer still in flight to the banner instead of to nobody. - .onAppear { git.noteFormVisible(true) } - .onDisappear { git.noteFormVisible(false) } - } -} - /// **The repo-nested explanation** (06-history-undo.md ▸ Rules), worded as the design words it: /// short prose in place of an action, never a hidden or greyed-out add-git. private struct BoardGitNestedNote: View { diff --git a/Kanban/UI/Board/BoardSettingsSheet.swift b/Kanban/UI/Board/BoardSettingsSheet.swift new file mode 100644 index 0000000..e5031eb --- /dev/null +++ b/Kanban/UI/Board/BoardSettingsSheet.swift @@ -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, + 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) } + } +} diff --git a/KanbanTests/BoardInfoPopoverTests.swift b/KanbanTests/BoardInfoPopoverTests.swift index 05aac4e..6048970 100644 --- a/KanbanTests/BoardInfoPopoverTests.swift +++ b/KanbanTests/BoardInfoPopoverTests.swift @@ -47,21 +47,23 @@ struct BoardGitSectionTests { #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() { - #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) } - @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() { 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 // 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 != .addGit) + #expect(section != .noRepository) } @Test("Every posture is reachable, and none of them is two postures") diff --git a/KanbanTests/BoardSettingsSheetTests.swift b/KanbanTests/BoardSettingsSheetTests.swift new file mode 100644 index 0000000..7c4dd00 --- /dev/null +++ b/KanbanTests/BoardSettingsSheetTests.swift @@ -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) + } +} diff --git a/KanbanTests/CaretChordTests.swift b/KanbanTests/CaretChordTests.swift index 9e3175d..ebdc648 100644 --- a/KanbanTests/CaretChordTests.swift +++ b/KanbanTests/CaretChordTests.swift @@ -1,15 +1,16 @@ import Testing @testable import Kanban -/// `caretChordsYield(boardInfo:search:)` — 04-interactions.md ▸ Grammar's caret-chords rule as one -/// expression, and `BoardCommands.swift`'s single seam for it: Board ▸ Move Left/Move Right ⌘←/⌘→ and -/// the lane-width pair ⌥⌘←/⌥⌘→ disable via menu validation whenever *any* text control has keyboard -/// focus, because ⌘←/⌘→ are the standard line-start/end caret chords and an enabled key equivalent -/// fires before a field ever sees the key. +/// `caretChordsYield(boardInfo:boardSettings:search:)` — 04-interactions.md ▸ Grammar's caret-chords +/// rule as one expression, and `BoardCommands.swift`'s single seam for it: Board ▸ Move Left/Move +/// Right ⌘←/⌘→ and the lane-width pair ⌥⌘←/⌥⌘→ disable via menu validation whenever *any* text +/// control has keyboard focus, because ⌘←/⌘→ are the standard line-start/end caret chords and an +/// 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 -/// `BoardInfoPresentation` and `BoardSearchPresentation` directly rather than through a `BoardStore` — -/// a fixture that stood up a board would be exercising machinery this seam never touches. +/// The function reads exactly three flags and nothing else, so every test here constructs +/// `BoardInfoPresentation`, `BoardSettingsPresentation` and `BoardSearchPresentation` directly rather +/// than through a `BoardStore` — a fixture that stood up a board would be exercising machinery this +/// seam never touches. @MainActor @Suite("caretChordsYield ▸ the caret-chords rule") struct CaretChordTests { @@ -21,7 +22,7 @@ struct CaretChordTests { // "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 // 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 @@ -29,13 +30,46 @@ struct CaretChordTests { @Test("The board popover open yields; closed, it does not") func popoverPresence() { 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 - #expect(caretChordsYield(boardInfo: boardInfo, search: nil) == true) + #expect(caretChordsYield(boardInfo: boardInfo, boardSettings: nil, search: nil) == true) 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 @@ -43,13 +77,13 @@ struct CaretChordTests { @Test("The search field focused yields; unfocused, it does not") func searchFocus() { 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 - #expect(caretChordsYield(boardInfo: nil, search: search) == true) + #expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: search) == true) 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 @@ -64,22 +98,32 @@ struct CaretChordTests { focusedSearch.isFocused = true // One side published and inert, the other absent (the still-loading-window shape): false. - #expect(caretChordsYield(boardInfo: closedInfo, search: nil) == false) - #expect(caretChordsYield(boardInfo: nil, search: unfocusedSearch) == false) + #expect(caretChordsYield(boardInfo: closedInfo, boardSettings: nil, search: nil) == false) + #expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: unfocusedSearch) == false) // One side published and active, the other absent: true. - #expect(caretChordsYield(boardInfo: openInfo, search: nil) == true) - #expect(caretChordsYield(boardInfo: nil, search: focusedSearch) == true) + #expect(caretChordsYield(boardInfo: openInfo, boardSettings: nil, search: nil) == 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 // 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 // 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: closedInfo, search: focusedSearch) == true) - #expect(caretChordsYield(boardInfo: openInfo, search: focusedSearch) == true) + #expect(caretChordsYield(boardInfo: openInfo, boardSettings: nil, search: unfocusedSearch) == true) + #expect(caretChordsYield(boardInfo: closedInfo, boardSettings: nil, 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 @@ -98,6 +142,6 @@ struct CaretChordTests { // No board popover, no search focus — caretChordsYield answers false regardless of the open // 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) } } diff --git a/KanbanUITests/AccessibilityAuditTests.swift b/KanbanUITests/AccessibilityAuditTests.swift index 1b66f15..d7124a0 100644 --- a/KanbanUITests/AccessibilityAuditTests.swift +++ b/KanbanUITests/AccessibilityAuditTests.swift @@ -5,12 +5,24 @@ import XCTest /// /// > **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, -/// > 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 /// 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 /// /// 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() } + + // 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() + } } diff --git a/KanbanUITests/AccessibilityVerification.md b/KanbanUITests/AccessibilityVerification.md index b492d31..16ae46c 100644 --- a/KanbanUITests/AccessibilityVerification.md +++ b/KanbanUITests/AccessibilityVerification.md @@ -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 -`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 \ @@ -51,11 +51,23 @@ The nine surfaces, and how each test gets there: | `testWelcomeWindow` | Welcome, with a recents row | Window ▸ Welcome to Lanework | | `testTemplateChooser` | Template chooser | File ▸ New Board… | | `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). 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 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].