The Git tab fills in — the parked postures rehome whole, and the strip's membership is the posture

The popover's third tab settles (03 ▸ Git tab, 2026-08-07): BoardGitTabView
takes the parked section — the six-posture matrix, the Pro pointer, the
nested/unverifiable prose, the branch controls, the Board Settings… door —
with the tab label doing the old "Git" header's naming work, and one new
caption for the no-repository posture. BoardInfoTab.available drops the Git
tab exactly where BoardGitSection resolves absent, carrying 12's
absent-no-placeholder rule up to the tab strip; selection resets to Info per
open, now a ruling. Daily face only — the remote block still joins with
pro-m2.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 19:11:55 -04:00
parent 7ac34651a2
commit 7d7e892617
4 changed files with 459 additions and 282 deletions
+80 -277
View File
@@ -4,16 +4,17 @@ import SwiftUI
/// **The board popover** "the one board-level surface" (03-board-ui.md § Board popover), and the
/// widget in the window's titlebar that opens it.
///
/// **Restructuring in progress (2026-08-07): the popover is going tabbed.** The symbol/name header
/// stays at the top; below it sit three tabs **Info**, **Theme**, **Git** each a settings
/// surface for one aspect of board configuration, each settled in its own dedicated design session.
/// **Info and Theme are settled** (both 2026-08-07 `BoardInfoTabView`, the metrics dossier;
/// `BoardThemeTabView`, the Solid color / Pattern picker the Background tab's original name, before
/// the same session widened it past the generated-only picker and folded manual styling back out to
/// Style S); Git remains deliberately empty until its own session. The former body's mode-aware
/// git section is unrendered for the interim but parked in this file (see the "Parked" mark below),
/// because its pure seams (`BoardGitSection`, the posture notes, `BoardSettingsAvailability`'s
/// caller) are settled design and will rehome into the Git tab once that session rules.
/// **Tabbed since 2026-08-07 the restructure is complete, all three tab sessions settled.** The
/// symbol/name header stays at the top; below it sit the tabs **Info**, **Theme**, **Git** each
/// the settings surface for one aspect of board configuration, each settled in its own dedicated
/// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color /
/// Pattern picker (the Background tab's original name, before the same session widened it past the
/// generated-only picker and folded manual styling back out to Style S); and `BoardGitTabView`,
/// which the pre-tab body's mode-aware git section rehomed into whole postures, notes, and the
/// Board Settings row, none of them re-ruled by the move.
///
/// **Tab membership is the git posture's** (`BoardInfoTab.available(tier:mode:hasGitDirectory:)`),
/// and **selection resets to Info on every open** both the Git session's rulings.
///
/// ### One home, deliberately
///
@@ -89,7 +90,7 @@ struct BoardInfoWidget: View {
@Bindable var presentation: BoardInfoPresentation
/// The window's settings sheet, so the popover's git section can carry the **Board Settings**
/// The window's settings sheet, so the popover's Git tab 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.
@@ -200,7 +201,7 @@ struct BoardInfoWidget: View {
/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its
/// folder, and the session's git posture pulled out so the fallback rule and the branch-visibility
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same
/// reason `BoardGitSection.resolve` exists one level down in this file.
/// reason `BoardGitSection.resolve` exists over in `BoardGitTabView.swift`.
///
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
@@ -279,9 +280,9 @@ func boardInfoTitlebarAccessory(
// MARK: - Tabs
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
/// restructure): **Info**, **Theme**, **Git**. Info and Theme are settled
/// (`BoardInfoTabView`, `BoardThemeTabView`); Git is a placeholder empty on purpose until
/// its own dedicated design session, which then only has to fill its case in.
/// restructure all three sessions settled): **Info** (`BoardInfoTabView`), **Theme**
/// (`BoardThemeTabView`), **Git** (`BoardGitTabView`). The raw values are the segmented control's own
/// labels, so the strip needs no separate label function.
enum BoardInfoTab: String, CaseIterable, Identifiable {
case info = "Info"
@@ -289,6 +290,27 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
case git = "Git"
var id: Self { self }
/// **Which tabs this board actually offers** membership *is* the git posture (03-board-ui.md
/// § Board popover, the Git session's ruling, 2026-08-07): the Git tab joins the strip only when
/// the git section has something true to say, and drops out exactly where `BoardGitSection`
/// resolves to `.absent` the free tier's ordinary board, which shows Info | Theme alone.
///
/// That carries 12-editions.md's "absent, no placeholder" rule up to the tab strip: a standing
/// Git tab on every free board would be the standing ad for Pro that 12 forbids, and an empty tab
/// behind it would be the placeholder 12 forbids twice over.
///
/// **Delegated, never restated.** The posture matrix is settled, test-pinned design
/// (`BoardGitSectionTests`), and a second reading of "is there git to show here" is a reading
/// that can disagree with the tab it labels so this asks `BoardGitSection.resolve` the same
/// question `BoardGitTabView` asks, from the same three facts.
///
/// Order is `allCases`' own Info, then Theme, then Git so the Git tab, where present, is
/// always last and the two standing tabs never move under a user who switched boards.
static func available(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> [BoardInfoTab] {
let posture = BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: hasGitDirectory)
return allCases.filter { $0 != .git || posture != .absent }
}
}
// MARK: - The popover's content
@@ -297,9 +319,9 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
/// (03-board-ui.md § Board popover).
///
/// Width is the style editor's the number that keeps the Style popover narrow enough to sit
/// beside a card kept through the restructure so the popover's footprint doesn't wander while Git
/// is still a placeholder; both tabs settled so far (Info, Theme) kept it too, so whether the
/// tabbed surface ever wants its own width remains open, but nothing has needed one yet.
/// beside a card kept through the restructure so the popover's footprint didn't wander while the
/// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface
/// ever wants its own width remains open, but nothing has needed one yet.
struct BoardInfoView: View {
let store: BoardStore
@@ -308,25 +330,22 @@ struct BoardInfoView: View {
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
/// The selected tab. `@State` on the popover's content, which `BoardInfoWidget` hands `.popover`
/// fresh on every open so the selection resets to Info per open. Whether the popover should
/// instead remember its last tab is a question for the tab sessions, once the tabs have content
/// worth returning to.
/// The selected tab, and **it resets to Info on every open** a ruling, not an accident (the
/// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover
/// is transient and Info is the board's face, and a remembered tab could strand selection on a
/// tab the next board's posture doesn't offer. `@State` on the popover's content, which
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
@State private var tab: BoardInfoTab = .info
/// 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).
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
///
/// **The free tier's input only.** Under Pro the section reads the session's detected mode
/// **The free tier's input only.** Under Pro the Git tab reads the session's detected mode
/// instead a fact settled at open, which is where 06-history-undo.md puts detection and this
/// stays what it always was: the one quiet question the free tier asks of a board's folder.
/// stays what it always was: the one quiet question the free tier asks of a board's folder. Since
/// the tab restructure it answers for the tab *strip* as well as for the tab's content
/// (`tabs` below), which is why it is read once here and handed down rather than asked twice.
private let hasGitDirectory: Bool
/// The style editor brings its own padding, so the sections around it carry the same number by
@@ -337,6 +356,21 @@ struct BoardInfoView: View {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
/// **The tab strip's membership** (`BoardInfoTab.available`), read inside `body` so the mode it
/// delegates to is live `HistoryStore` is `@Observable`, and add-git flipping a board's mode
/// must not leave a stale strip behind it.
///
/// **Stable across an open, in practice and by construction.** `tier` is the session's, fixed at
/// the widget's birth (12-editions.md The entitlement: "a lapse never interrupts an open
/// session"), and `hasGitDirectory` is one `init`-time read; the mode is the only live input, and
/// `BoardGitSection.resolve` answers `.absent` only under the free tier, where it never consults
/// the mode at all. So no live change can move the Git tab in or out from under the selection.
/// Add-git the one gesture that flips a mode runs from the settings sheet anyway, which the
/// Board Settings row dismisses this popover to reach.
private var tabs: [BoardInfoTab] {
BoardInfoTab.available(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory)
}
init(
store: BoardStore,
recents: StyleRecents,
@@ -398,9 +432,10 @@ struct BoardInfoView: View {
// The tab bar: a segmented control rather than a `TabView`, because the popover is a
// compact settings surface and the segmented idiom is the macOS shape for switching
// between a handful of peer panes inside one. The label is hidden visually but stays
// the control's accessibility name.
// the control's accessibility name. It iterates `tabs`, not `allCases` membership is
// this board's git posture (`BoardInfoTab.available`).
Picker("Board configuration", selection: $tab) {
ForEach(BoardInfoTab.allCases) { tab in
ForEach(tabs) { tab in
Text(tab.rawValue)
}
}
@@ -409,125 +444,29 @@ struct BoardInfoView: View {
.padding(.horizontal, inset)
.padding(.top, inset)
// The selected tab's surface. Info and Theme are settled (2026-08-07
// `BoardInfoTabView`, `BoardThemeTabView`); Git stays a placeholder until its own
// session, holding a fixed height so an empty tab reads as a surface awaiting content
// rather than a collapsed sliver `Color.clear`, because an `EmptyView` inside a frame
// renders nothing at all.
// The selected tab's surface all three settled 2026-08-07, each in its own dedicated
// session and its own file. Each pads itself by `inset`, so the switch adds nothing.
switch tab {
case .info:
BoardInfoTabView(store: store, inset: inset)
case .theme:
BoardThemeTabView(store: store, inset: inset)
case .git:
Color.clear.frame(height: 120)
BoardGitTabView(
store: store,
tier: tier,
git: git,
settings: settings,
hasGitDirectory: hasGitDirectory,
inset: inset
)
}
}
// The style editor's popover width, taken from the editor rather than restated: the embed
// below must lay out here exactly as it does at its other two anchors, and that number is
// now font-derived.
// The style editor's popover width, taken from the editor rather than restated the number
// that keeps a compact settings popover narrow enough to sit beside a card. It is
// font-derived, so the whole surface scales with the grids inside it (10-accessibility.md).
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
}
// MARK: Parked pending the Git tab session (2026-08-07)
//
// Nothing below this mark renders today. The style-editor embed that once lived here briefly
// rehomed to `BoardThemeTabView` and has since moved back out of the popover entirely manual
// board styling is reachable through Style S, and the Theme tab's Solid color / Pattern picker
// covers the same ground its "Board" background grid did. What is left below is the git section
// postures, notes, and the Board Settings row waiting for the Git tab's own session. Parked
// rather than deleted because every seam it hangs on is settled, test-pinned design
// (`BoardGitSectionTests`, `BoardSettingsAvailabilityTests`), and the tab sessions rehome
// surfaces, not rulings.
/// The popover's closing section, whichever of the six postures this board is in see
/// `BoardGitSection`.
@ViewBuilder
private var gitSection: some View {
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
case .absent:
EmptyView()
case .proPointer:
Divider()
BoardGitNote()
.padding(inset)
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")
boardSettingsRow
}
.padding(inset)
case .repoNested:
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
BoardGitNestedNote()
}
.padding(inset)
case .unverifiable:
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
BoardGitUnverifiableNote()
}
.padding(inset)
case .branch:
Divider()
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
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,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
) {
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 {
Text(title)
.font(.subheadline.weight(.semibold))
}
}
// MARK: - Rename
@@ -605,139 +544,3 @@ private struct BoardRenameField: View {
store.rootURL.deletingPathExtension().lastPathComponent
}
}
// MARK: - Git
/// **What the popover's git slot is, for one board** (03-board-ui.md Board popover;
/// 06-history-undo.md Rules; 12-editions.md The free tier and `.git`) a pure function of two
/// facts, so the posture matrix is provable without a popover on screen.
///
/// The free tier's two cases are settled 2026-07-27 and unchanged by this card: absent on an
/// ordinary board, a one-line Pro pointer on a board carrying an inert `.git`. The Pro cases are the
/// 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).
/// `unverifiable` (the git-detection axis) joins as a fourth Pro case, structurally identical to
/// `repoNested` but worded as its own honest prose a denial is not a nesting.
///
/// **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,
/// complete in itself".
case absent
/// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer
/// to Pro (12 Tier naming).
case proPointer
/// 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 and no Board Settings row either,
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
case repoNested
/// Pro, unverifiable: **not** `.repoNested` a denied ancestor check, not a found repository
/// (06 Rules Detection, "Denial is not absence"). Structurally identical to `.repoNested`
/// (no action, no Board Settings row, `BoardSettingsAvailability` false), but its own case so
/// the view renders its own honest prose rather than the nested sentence "unverifiable" is not
/// "nested".
case unverifiable
/// 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 {
switch tier {
case .free:
// Detection never runs under the free tier, so the mode is not consulted here the one
// question asked is whether the folder carries a `.git`, which is what the pointer is
// about (12: "any `.git` is inert a stray like any other, preserved verbatim").
return hasGitDirectory ? .proPointer : .absent
case .pro:
switch mode {
case .none: return .noRepository
case .git: return .branch
case .repoNested: return .repoNested
case .unverifiable: return .unverifiable
}
}
}
}
/// **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 {
var body: some View {
Text("This board lives inside a repository; Lanework leaves it to that repository.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// **The unverifiable explanation** (06-history-undo.md Rules Detection, "Denial is not
/// absence", ruled 2026-07-31), worded as its own honest sentence rather than borrowing
/// `BoardGitNestedNote`'s a denied ancestor check is not a found repository, and telling a user
/// their board is nested when the truth is "couldn't check" would be a lie dressed as caution.
private struct BoardGitUnverifiableNote: View {
var body: some View {
Text("Lanework couldn't verify whether this board sits inside a repository, so it isn't offering to add one here.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// The contextual git note **a quiet signpost, not a feature** (12-editions.md The free tier and
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is
/// one line, shown only when it is true and it is **the one in-context pointer to Pro**, the second
/// of the three places the app names it (12 Tier naming; the other two are `AboutBox` and the
/// Settings Pro section).
///
/// On an ordinary board `BoardInfoView` never instantiates this type at all the section is
/// *absent*, matching the card window's absent History section (12: "absent, no placeholder"). Only
/// a board that carries an inert `.git` (12 the inert posture: "any `.git` is inert" the free
/// tier never reads or writes it, whether the board's own or one a lapsed subscription left behind)
/// earns this note, worded exactly as 12 rules: an honest explanation of what the folder is, named
/// exactly where the question arises, never a standing ad for Pro.
///
/// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly
/// (a fixture board with `.git` true, without false), which needs it visible past this file even
/// though nothing outside `BoardInfoPopover.swift` calls it in the app itself.
struct BoardGitNote: View {
var body: some View {
Text("This board has a git history. Lanework Pro works with it.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
/// Whether `boardRoot` carries a `.git` entry the free tier's entire detection story, and a deliberately
/// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact
/// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder
/// watch by design (§ .git filtering it exists to ignore git churn), so there is no reload
/// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken
/// once, at the moment the popover is built, is the honest amount of machinery for a single quiet
/// line. A `.git` added or removed while the popover happens to be open is stale until the next
/// open a gap this note's own posture makes harmless, since it is a signpost, not a control.
static func hasGitDirectory(at boardRoot: URL) -> Bool {
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
}
}