The git surfaces leave the glass — tab, trail, branch line, and the remote pair, peeled

Step 3 of strategy/01-git-excision.md: the popover strip is Info/Theme/Sync, the titlebar widget says the name alone, the card window's History section and its slot go, Board ▸ Pull/Push comes out with the RemoteCommands scaffold, and View ▸ History re-tags from the commit trail to the deferred foreign-change journal. The Sync placeholder re-annotates to the future ops-based sync service. Four git test suites leave with the surfaces they pinned (BoardGitSetup, BoardInfoPopover, BranchSwitch, GitUndo). The git engine still compiles underneath, unreferenced by UI. 2,890 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 10:52:57 -04:00
parent ae7be98eaa
commit 1d97a2931c
16 changed files with 119 additions and 4525 deletions
+70 -149
View File
@@ -4,21 +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.
///
/// **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 and notes, none of
/// them re-ruled by the move. That tab carried a **Board Settings** row to a separate sheet until
/// later the same day, when the 2026-07-31 popover/sheet split was reversed and the sheet's contents
/// rehomed into the tab (see below).
/// **Tabbed since 2026-08-07.** The symbol/name header stays at the top; below it sit the tabs
/// **Info**, **Theme**, **Sync** 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 `BoardSyncTabView`, a standing placeholder for the future ops-based sync service.
/// The Git tab that once sat here left with the git excision (strategy/01-git-excision.md,
/// 2026-08-08).
///
/// **Tab membership is the git posture's**, and **selection resets to Info on every open** both
/// the Git session's rulings. Since 12-editions.md PIVOT 2026-08-07 the first of those is a
/// structural rule with nothing left to exclude: every board carries all three tabs (03 Board
/// popover, the same-day pivot note) see `BoardInfoTab`.
/// **Selection resets to Info on every open** the tab session's ruling, unaffected by the git
/// excision: see `BoardInfoTab`.
///
/// ### One home, deliberately
///
@@ -29,11 +25,9 @@ import SwiftUI
///
/// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31
/// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and
/// a row here pointing at it. The sheet is retired, its add-git and commit-identity controls render
/// inline in the Git tab's postures (`BoardGitSetup.swift`), branch creation went back into the
/// switch menu (`BoardGitControls`), and Board Board Settings left the menu bar with them. So
/// "one home per control" the split's own promise is now satisfied by there being one surface,
/// and I is the door to all of it.
/// a row here pointing at it. The sheet is retired and Board Board Settings left the menu bar with
/// it. So "one home per control" the split's own promise is now satisfied by there being one
/// surface, and I is the door to all of it.
// MARK: - Presentation state
@@ -74,17 +68,13 @@ extension FocusedValues {
// MARK: - The window-title widget
/// The titlebar widget: the board's glyph beside a two-line identity block its name, and on a
/// git-mode board its branch under the name with a trailing disclosure chevron, whose one job is
/// this popover.
/// The titlebar widget: the board's glyph beside its name, with a trailing disclosure chevron, whose
/// one job is this popover.
///
/// **A two-line stack since 2026-08-07** (03-board-ui.md Board popover, the window-title widget
/// passage). It was one line reading `glyph Title branch `, and the em-dash was the tell: a
/// separator doing a *hierarchy's* job, with the branch competing for the same width as the name it
/// qualifies. So the branch moved under the title in its own smaller, secondary line, the em-dash
/// retired, and the glyph grew to span both lines an icon sized to the block it labels rather than
/// to whichever line it happened to sit on. A board with no branch is the single title line, vertically
/// centred beside the same glyph, which is the same block with one row.
/// **Was a two-line stack** (03-board-ui.md Board popover, the window-title widget passage): a
/// git-mode board's branch sat under the name in its own smaller, secondary line. The branch line
/// left with app-managed git (strategy/01-git-excision.md, 2026-08-08); the widget is back to the
/// single title line, vertically centred beside the glyph.
///
/// **The popover is anchored to the widget itself** it hangs from the button rather than from the
/// window or the board which is what makes the affordance and the surface read as one thing. A
@@ -93,37 +83,25 @@ extension FocusedValues {
/// only the *placement* (`boardInfoTitlebarAccessory`).
///
/// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron
/// button to the full name/branch/chevron button): the title and branch strings sit inside the same
/// `Button`, so a click anywhere across the board's name or its branch, when shown opens the
/// popover exactly as a click on the chevron always has.
/// button to the full name/chevron button): the title string sits inside the same `Button`, so a
/// click anywhere across the board's name opens the popover exactly as a click on the chevron
/// always has.
struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
/// The git state this board's **session** composed with read once, at the moment the widget is
/// installed, and never re-derived (12-editions.md The entitlement: "a lapse never interrupts
/// an open session"). `git` is a reference type and `@Observable`, so add-git flipping the mode,
/// or a branch switch, redraws the widget without anything here being re-created.
///
/// It stopped being a tier signal at 12 PIVOT 2026-08-07 every session composes a git state
/// now, whatever the tier so a `nil` here means only "this caller had no session to hand over"
/// (previews, the accessory-installation tests), which reads as mode `none`.
let git: HistoryStore?
@Bindable var presentation: BoardInfoPresentation
/// 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
/// never see a later rename or branch switch. `store.snapshot` and `git.branch` are both
/// `@Observable`, so reading them here is what makes the title and branch live.
/// The widget's title string, 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 never see a later rename. `store.snapshot` is `@Observable`, so reading it
/// here is what makes the title live.
private var summary: BoardInfoTitlebarSummary {
BoardInfoTitlebarSummary(
snapshotTitle: store.snapshot.title.value,
rootURL: store.rootURL,
mode: git?.mode ?? .none,
branch: git?.branch
rootURL: store.rootURL
)
}
@@ -151,33 +129,17 @@ struct BoardInfoWidget: View {
// everything VoiceOver needs (`accessibilityLabel` below).
.accessibilityHidden(true)
// The identity block: the name, and the branch beneath it on a git-mode board. On
// any other board this is the single title line and the `HStack`'s own centring puts
// it level with the glyph the stack is the same shape with one row, never a
// special case.
VStack(alignment: .leading, spacing: 1) {
Text(summary.title)
// Styled like a titlebar title, because that is what it now stands in for
// (`BoardWindowHost` hides the system title display in favor of this widget).
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
if let branch = summary.branch {
// Smaller and secondary the qualifier under the name it qualifies. The
// em-dash that separated the two on one line retired with the stack: a
// hierarchy that a layout can state does not need punctuation to state it.
Text(branch)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
// Yields space to the chevron first when the block doesn't fit inside the width cap
// below the board's identity is the more load-bearing half of the pair, and both
// its lines truncate rather than the disclosure disappearing.
// The identity block: the board's name. The `HStack`'s own centring puts it level
// with the glyph.
Text(summary.title)
// Styled like a titlebar title, because that is what it now stands in for
// (`BoardWindowHost` hides the system title display in favor of this widget).
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
// Yields space to the chevron first when the title doesn't fit inside the width cap
// below the board's identity is the more load-bearing half of the pair.
.layoutPriority(1)
Image(systemName: "chevron.down")
@@ -186,11 +148,9 @@ struct BoardInfoWidget: View {
.foregroundStyle(.secondary)
}
.font(.system(size: 13))
// A long board name (or branch) must not swallow the whole titlebar capped rather
// than left to grow, with the truncation above doing the rest. The height is the
// two-line block's (2026-08-07; it was the original chevron's 18 while the widget was
// one line), which is what keeps the accessory titlebar-appropriate: tall enough for
// name-over-branch, and no taller than a standard title bar carries.
// A long board name must not swallow the whole titlebar capped rather than left to
// grow, with the truncation above doing the rest. The height keeps the accessory
// titlebar-appropriate: no taller than a standard title bar carries.
.frame(maxWidth: 400, alignment: .leading)
.frame(height: 32)
.contentShape(Rectangle())
@@ -199,24 +159,16 @@ struct BoardInfoWidget: View {
.help("Board Info")
.accessibilityLabel(accessibilityLabel)
.accessibilityHint("Shows board info")
// Populates the branch line the moment a git-mode board's window opens, rather than waiting
// on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover
// has actually been opened once). The widget is on screen from the start, so it is the
// earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this
// costs nothing on the other four postures.
.task { await git?.refreshBranch() }
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents, git: git)
BoardInfoView(store: store, recents: recents)
}
}
/// What VoiceOver reads for the button, now that it says more than "Board Info": the board's
/// name, plus the branch when the widget is showing one `.help` keeps the shorter "Board Info"
/// wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names what
/// the button does.
/// What VoiceOver reads for the button: the board's name `.help` keeps the shorter "Board
/// Info" wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names
/// what the button does.
private var accessibilityLabel: String {
guard let branch = summary.branch else { return summary.title }
return "\(summary.title), branch \(branch)"
summary.title
}
/// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
@@ -231,10 +183,9 @@ struct BoardInfoWidget: View {
// MARK: - The widget's strings
/// **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 over in `BoardGitTabView.swift`.
/// **The window-title widget's title string, as one pure function** of the board's on-disk title and
/// its folder pulled out so the fallback rule is assertable without a widget on screen
/// (`BoardInfoTitlebarSummaryTests`).
///
/// **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
@@ -245,26 +196,19 @@ struct BoardInfoWidget: View {
/// with plain values and no fixture board on disk the one duplication this card leaves behind
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
///
/// **Branch.** Shown only when the board is actually git-mode `mode == .git` with a non-`nil`
/// branch the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause
/// is gone** (12-editions.md PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a
/// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode ==
/// .git` until that day is recorded here rather than restated in code. A board with no repository or
/// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows no branch; neither does a
/// git-mode board whose branch has not been read yet (`HistoryStore.branch` starts `nil` until
/// `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
/// **Branch.** The widget once carried a second line the board's git branch, shown on a git-mode
/// board as a `branch` field here. That line left with app-managed git
/// (strategy/01-git-excision.md, 2026-08-08); the summary is the title alone now.
struct BoardInfoTitlebarSummary: Equatable {
let title: String
let branch: String?
init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) {
init(snapshotTitle: String?, rootURL: URL) {
if let snapshotTitle, !snapshotTitle.isEmpty {
self.title = snapshotTitle
} else {
self.title = rootURL.deletingPathExtension().lastPathComponent
}
self.branch = mode == .git ? branch : nil
}
}
@@ -276,21 +220,15 @@ 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
/// `git` defaults to no session, so that a caller with none in hand (the accessory-installation
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
/// than by accident: a Git tab in its no-repository posture, and a widget with no branch line. The
/// app's own call site passes the session's value explicitly.
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
git: HistoryStore? = nil,
presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
store: store,
recents: recents,
git: git,
presentation: presentation
)
)
@@ -314,31 +252,22 @@ func boardInfoTitlebarAccessory(
// MARK: - Tabs
/// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
/// restructure all three original sessions settled): **Info** (`BoardInfoTabView`), **Theme**
/// (`BoardThemeTabView`), **Git** (`BoardGitTabView`) and **Sync** (`BoardSyncTabView`), added
/// 2026-08-07 as a standing placeholder: the strip claims the position now, the surface says
/// honestly that nothing lives there yet, and 07-sync-collab.md's cards are where its contents get
/// ruled. The raw values are the segmented control's own labels, so the strip needs no separate
/// label function.
/// restructure): **Info** (`BoardInfoTabView`), **Theme** (`BoardThemeTabView`) and **Sync**
/// (`BoardSyncTabView`), added 2026-08-07 as a standing placeholder: the strip claims the position
/// now, the surface says honestly that nothing lives there yet, and the future sync-service
/// workstream is where its contents get ruled. A fourth tab, Git, sat here until the git excision
/// (strategy/01-git-excision.md, 2026-08-08) removed it. 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"
case theme = "Theme"
case git = "Git"
case sync = "Sync"
var id: Self { self }
// **Membership is the git posture's, and the posture never says "absent" any more.** The Git
// session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has
// something true to say, which then meant dropping it on a free board with no `.git` the
// "absent, no placeholder" rule carried up to the strip. **12-editions.md PIVOT 2026-08-07**,
// the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and
// `.proPointer` died with it, and 03-board-ui.md Board popover records the consequence "the
// absent posture is unreachable and every board carries all three tabs. The membership rule
// stands structurally the strip still asks the posture it just never hears 'absent'
// anymore." So there is no `available()` filter here to ask it with: membership is `allCases`,
// in `allCases`' own order (Info, Theme, Git, Sync), which is what the filter answered anyway.
// Membership is simply `allCases`, in `allCases`' own order (Info, Theme, Sync) every board
// carries the whole strip, unconditionally.
}
// MARK: - The popover's content
@@ -348,18 +277,16 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
///
/// 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 didn't wander while the
/// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface
/// tabs filled in; every settled tab (Info, Theme, Sync) 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
let recents: StyleRecents
let git: HistoryStore?
/// 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
/// The selected tab, and **it resets to Info on every open** a ruling, not an accident: the
/// popover is transient and Info is the board's face, and a remembered tab could strand
/// selection on a tab the next board 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
@@ -373,12 +300,10 @@ struct BoardInfoView: View {
init(
store: BoardStore,
recents: StyleRecents,
git: HistoryStore? = nil
recents: StyleRecents
) {
self.store = store
self.recents = recents
self.git = git
}
var body: some View {
@@ -425,10 +350,8 @@ 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. It iterates `allCases` the strip's membership is
// still the board's git posture in principle (the Git session's ruling), and since
// 12-editions.md PIVOT 2026-08-07 that posture is never "absent", so every board
// carries the whole strip (03-board-ui.md Board popover, the same-day pivot note).
// the control's accessibility name. It iterates `allCases` every board carries the
// whole strip, unconditionally (03-board-ui.md Board popover).
Picker("Board configuration", selection: $tab) {
ForEach(BoardInfoTab.allCases) { tab in
Text(tab.rawValue)
@@ -439,7 +362,7 @@ struct BoardInfoView: View {
.padding(.horizontal, inset)
.padding(.top, inset)
// The selected tab's surface the first three settled 2026-08-07, each in its own
// The selected tab's surface Info and Theme settled 2026-08-07, each in its own
// dedicated session and its own file; Sync is that day's standing placeholder. Each pads
// itself by `inset`, so the switch adds nothing.
switch tab {
@@ -447,8 +370,6 @@ struct BoardInfoView: View {
BoardInfoTabView(store: store, inset: inset)
case .theme:
BoardThemeTabView(store: store, inset: inset)
case .git:
BoardGitTabView(store: store, git: git, inset: inset)
case .sync:
BoardSyncTabView(inset: inset)
}