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

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

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 12:07:12 -04:00
parent 988a7245a3
commit 8345378972
12 changed files with 1168 additions and 321 deletions
+177
View File
@@ -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)
}
}