The board settings sheet — setup leaves the popover for a home a stray click can't dismiss
The popover/sheet split lands: a board-scoped, titled, sectioned sheet on the board window hosts everything setup-shaped, opened from the popover's Board Settings… row and the new Board ▸ Board Settings… menu row. The three existing setup controls relocate — add-git (whose noteFormVisible lines now make the sheet the form-anchored failure surface), branch creation (a standing field; create-and-switch runs the identical settle sequence), and the commit-identity fields (the 2s visibility-scoped poll rides with them). The popover keeps the daily face and its postures; its Pro/mode-none section becomes header + door (.addGit renamed .noRepository). Availability is derived from the section inventory (Pro + mode none or git), so pro-m2's sections can't drift from the doors; the sheet's fields join the caret-chord disable set. The audit suite pins what the free fixture can reach; the sheet's own audit is manual until a tier override is ruled. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The board settings sheet's two pure seams** (03-board-ui.md ▸ Board settings sheet, ruled
|
||||
/// 2026-07-31 — the popover/sheet split; 04-interactions.md ▸ The map's configuration carve-out):
|
||||
/// what the sheet holds for a board, and therefore whether the sheet exists for that board at all.
|
||||
///
|
||||
/// They are pinned here for `BoardGitSection.resolve`'s reason one surface over: the *inventory* is
|
||||
/// the decision worth asserting and the SwiftUI that renders it is not. Every case below is a plain
|
||||
/// value — no board on disk, no window, no session.
|
||||
@Suite("Board settings sheet ▸ the sections")
|
||||
struct BoardSettingsSectionTests {
|
||||
|
||||
@Test("Pro, mode none: the sheet is add-git and nothing else")
|
||||
func modeNoneHoldsAddGit() {
|
||||
// "add-git (mode none; opt-in init — 06)" — 03's own first entry for this surface, and the
|
||||
// control that moved here out of the popover with the split.
|
||||
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .none) == [.git])
|
||||
}
|
||||
|
||||
@Test("Pro, git mode: branch creation and the commit identity, in that order")
|
||||
func gitModeHoldsCreationAndIdentity() {
|
||||
// "branch creation (switching stays in the popover…)" and "commit identity name/email (06 —
|
||||
// the visibility-scoped 2 s config re-read rides with the fields)".
|
||||
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity])
|
||||
}
|
||||
|
||||
@Test("Every section is reachable from some posture, and no posture invents one")
|
||||
func theInventoryIsTotal() {
|
||||
let offered = Set(
|
||||
Tier.allCases.flatMap { tier in
|
||||
BoardGitMode.allCases.flatMap { BoardSettingsSection.resolve(tier: tier, mode: $0) }
|
||||
}
|
||||
)
|
||||
|
||||
#expect(offered == Set(BoardSettingsSection.allCases))
|
||||
}
|
||||
|
||||
@Test("The sections carry the headers VoiceOver navigates by")
|
||||
func headersAreNamed() {
|
||||
// 10-accessibility.md ▸ Board settings sheet: "titled and sectioned with headers VoiceOver
|
||||
// can navigate by". The strings are the surface's spoken structure, so they are stated once
|
||||
// and pinned once.
|
||||
#expect(BoardSettingsSection.git.title == "Git")
|
||||
#expect(BoardSettingsSection.branch.title == "Branch")
|
||||
#expect(BoardSettingsSection.commitIdentity.title == "Commit Identity")
|
||||
}
|
||||
}
|
||||
|
||||
/// **Where the sheet can be opened from, tier by mode** — the answer both doors validate on: Board ▸
|
||||
/// Board Settings…'s `disabled` state, and whether the popover's git section renders its Board
|
||||
/// Settings… row at all.
|
||||
@Suite("Board settings sheet ▸ availability")
|
||||
struct BoardSettingsAvailabilityTests {
|
||||
|
||||
@Test("The whole matrix: Pro on a none-or-git board, and nowhere else")
|
||||
func theMatrix() {
|
||||
// Pro, and a board whose mode leaves something to set up.
|
||||
#expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .none))
|
||||
#expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .git))
|
||||
|
||||
// **Pro, repo-nested**: "nothing setup-shaped can apply" — no add-git (06's prose, not a
|
||||
// disabled button), no branch of ours to create, no repo-local config of ours to write. The
|
||||
// popover's explanation stands and no door opens.
|
||||
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .repoNested))
|
||||
|
||||
// **The free tier**: no setup exists there at all (12-editions.md ▸ The free tier and
|
||||
// `.git`), whatever mode a stray value claims — detection never runs off Pro, so the mode is
|
||||
// swept for completeness rather than because it can vary.
|
||||
for mode in BoardGitMode.allCases {
|
||||
#expect(
|
||||
!BoardSettingsAvailability.resolve(tier: .free, mode: mode),
|
||||
"the free tier has no board settings sheet in any mode"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Reachable means exactly 'has something to show'")
|
||||
func reachabilityIsTheInventory() {
|
||||
// The derivation, not a coincidence: a surface whose whole job is hosting setup controls has
|
||||
// no honest empty state, so the two answers are one answer. pro-m2's sections join the
|
||||
// inventory and this identity keeps holding.
|
||||
for tier in Tier.allCases {
|
||||
for mode in BoardGitMode.allCases {
|
||||
#expect(
|
||||
BoardSettingsAvailability.resolve(tier: tier, mode: mode)
|
||||
== !BoardSettingsSection.resolve(tier: tier, mode: mode).isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The window's sheet flag** (`BoardSettingsPresentation`) — `BoardInfoPresentation`'s sibling,
|
||||
/// with the one thing the popover flag does not carry: the session posture both doors validate on.
|
||||
@MainActor
|
||||
@Suite("Board settings sheet ▸ the window's presentation")
|
||||
struct BoardSettingsPresentationTests {
|
||||
|
||||
@Test("It starts closed, unadopted, and unreachable")
|
||||
func startsClosed() {
|
||||
let presentation = BoardSettingsPresentation()
|
||||
|
||||
#expect(presentation.isPresented == false)
|
||||
#expect(presentation.tier == .free)
|
||||
#expect(presentation.git == nil)
|
||||
// The free tier's posture is the safe default for a window whose session has not been adopted
|
||||
// yet: an unreachable sheet, rather than a sheet with no sections in it.
|
||||
#expect(presentation.isReachable == false)
|
||||
#expect(presentation.sections.isEmpty)
|
||||
}
|
||||
|
||||
@Test("An unreachable board's sheet refuses to present")
|
||||
func presentRefusesWhereUnreachable() {
|
||||
let presentation = BoardSettingsPresentation()
|
||||
|
||||
presentation.present()
|
||||
#expect(presentation.isPresented == false, "the free tier has no sheet to open")
|
||||
}
|
||||
|
||||
@Test("Two windows hold their own flags")
|
||||
func perWindow() {
|
||||
// `BoardInfoPresentation`'s rule, restated for the sheet: "two board windows each hold their
|
||||
// own and can never toggle each other's".
|
||||
let first = BoardSettingsPresentation()
|
||||
let second = BoardSettingsPresentation()
|
||||
|
||||
first.isPresented = true
|
||||
#expect(second.isPresented == false)
|
||||
}
|
||||
|
||||
@Test("Adopting a Pro git session makes the sheet reachable, and dismissal is idempotent")
|
||||
func adoptingASession() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
|
||||
// Mode `none` — the add-git posture, which is the sheet's whole job on a board with no
|
||||
// repository yet.
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(git.mode == .none)
|
||||
|
||||
let presentation = BoardSettingsPresentation()
|
||||
presentation.adopt(tier: .pro, git: git)
|
||||
|
||||
#expect(presentation.isReachable)
|
||||
#expect(presentation.sections == [.git])
|
||||
|
||||
presentation.present()
|
||||
#expect(presentation.isPresented)
|
||||
|
||||
presentation.dismiss()
|
||||
presentation.dismiss()
|
||||
#expect(presentation.isPresented == false)
|
||||
}
|
||||
|
||||
@Test("The free tier's session leaves both doors shut, .git on the folder or not")
|
||||
func aFreeTierSessionIsUnreachable() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
|
||||
|
||||
// `compose` is the tier gate: the free tier gets no git state at all, so there is nothing for
|
||||
// a settings sheet to be about even on a board carrying an inert `.git` — the popover's
|
||||
// one-line Pro pointer is that board's whole story (12-editions.md).
|
||||
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
|
||||
|
||||
let presentation = BoardSettingsPresentation()
|
||||
presentation.adopt(tier: .free, git: nil)
|
||||
|
||||
#expect(presentation.isReachable == false)
|
||||
presentation.present()
|
||||
#expect(presentation.isPresented == false)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import Testing
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user