The tier axis comes out of the git stack — compose unconditional, postures mode-driven

HistoryStore.compose(boardRoot📒) returns non-optional and runs for
every session — the nil the gate produced was the only nil it ever had.
makeHistoryProvider is a one-axis decision: git-mode boards bind the git
provider, everything else native, in every tier; Session.tier stays
recorded, dormant. BoardGitSection shrinks to the four mode postures
(.absent and .proPointer die, BoardGitNote and the .git probe with them);
every board carries all three popover tabs (BoardInfoTab.available
retired); the titlebar branch shows on any git-mode board; the settings
sheet and card History section stop reading tier. InertGitTests is
repurposed as UntouchedGitTests — the file layer still never opens .git,
now load-bearing for mode-none boards. The accessibility audit reaches the
settings sheet at last: the fixture board hosts it in every tier, so the
free-fixture disabled-row test becomes an open-and-audit test.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 20:21:28 -04:00
parent da0d7fd2d7
commit 798a8bac73
32 changed files with 830 additions and 904 deletions
+8 -4
View File
@@ -448,10 +448,14 @@ struct BoardInfoCommand: View {
/// ### 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.
/// which carries the reasoning): a board nested inside someone else's repository, one whose ancestor
/// check was denied, and one whose own repository will not open. 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 free tier used to be the fourth of those**, and is not since 12-editions.md PIVOT
/// 2026-08-07: git is tier-independent, so what this row validates on is the board in front of the
/// user and nothing about their subscription.
///
/// 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
+52 -117
View File
@@ -15,26 +15,25 @@ import SwiftUI
/// `sectionHeader("Git")` and a `Divider()`. The tabs already sit under the popover header's own
/// divider; a second rule and a second "Git" would be the surface naming itself twice.
///
/// ### Membership is the posture, so this view never renders an empty state
/// ### Every board has this tab (pivot 2026-08-07)
///
/// `BoardInfoTab.available(tier:mode:hasGitDirectory:)` drops the Git tab from the strip exactly when
/// `BoardGitSection` resolves to `.absent`, so this view is never built for the free tier's ordinary
/// board. That is 12-editions.md's "absent, no placeholder" rule carried up to the tab strip a
/// standing Git tab on every free board would be the standing ad for Pro that 12 forbids.
/// 12-editions.md PIVOT 2026-08-07 took git off the paywall, and 03's Git-tab note records what
/// that does to this surface: with no free-only postures left, every board resolves one of the four
/// mode postures below and **the tab is always in the strip**. The membership rule the Git session
/// ruled (the strip asks the posture) stands structurally in `BoardInfoTab` it simply never hears
/// "absent" any more, because there is no longer a posture that says nothing.
// MARK: - The posture
/// **What the Git tab shows, for one board** (03-board-ui.md Board popover Git tab;
/// 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.
/// 06-history-undo.md Rules) a pure function of the board's **mode**, so the posture matrix is
/// provable without a popover on screen.
///
/// The free tier's two cases are settled 2026-07-27: 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 four 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) is 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
@@ -43,59 +42,49 @@ import SwiftUI
/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its
/// test survived the move unchanged.
///
/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** the same six cases, now
/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** the same cases, now
/// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's
/// single pane. `.absent` grew one further job in the move: it is what `BoardInfoTab.available` reads
/// to decide whether the Git tab is in the strip at all, which is why membership can never disagree
/// with what the tab would have rendered.
/// single pane.
///
/// **The 2026-08-07 pivot took the tier axis out of it** (12-editions.md PIVOT 2026-08-07, the
/// same day). The two free-tier cases `.absent` on an ordinary board, `.proPointer` on a board
/// carrying an inert `.git`, both settled 2026-07-27 described a gate that no longer exists: git
/// is tier-independent, a `.git` at a board root is live in every tier, and detection runs at every
/// board open. So the free-tier branch, the `hasGitDirectory` input it asked for, and both cases are
/// **retired**, and what is left is the mode which is what the postures were always about.
enum BoardGitSection: Equatable, CaseIterable {
/// Nothing at all the free tier's ordinary board, where "the popover is rename + style,
/// complete in itself". Since the restructure this is a **missing tab**, not an empty one
/// (`BoardInfoTab.available`).
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 tab is one caption stating the fact above the Board Settings door, where
/// add-git now lives (03 Board settings sheet). The header-plus-door posture blessed
/// 2026-08-06, restated for a surface whose header is now the tab label.
/// Mode `none`: a board that could have a history and has none. There is no daily surface for
/// that the tab is one caption stating the fact above the Board Settings door, where add-git
/// now lives (03 Board settings sheet). The header-plus-door posture blessed 2026-08-06,
/// restated for a surface whose header is now the tab label.
///
/// **Every tier's posture since the pivot** (03 Git tab, pivot note 2026-08-07), and git stays
/// **opt-in per board**: the door is an offer, never an auto-init.
case noRepository
/// Pro, repo-nested: the honest explanation, no action and no Board Settings row either,
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
/// 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
/// 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.
/// 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
}
static func resolve(mode: BoardGitMode) -> BoardGitSection {
switch mode {
case .none: return .noRepository
case .git: return .branch
case .repoNested: return .repoNested
case .unverifiable: return .unverifiable
}
}
}
@@ -144,71 +133,29 @@ private struct BoardGitUnverifiableNote: View {
}
}
/// 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 nothing instantiates this type at all the board has no Git tab to put it in
/// (`BoardInfoTab.available`), 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), and `BoardInfoView.init` is its one caller
/// in the app the popover asks the question once at open and hands the answer down, both to this
/// tab and to `BoardInfoTab.available`.
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)
}
}
// MARK: - The tab
/// The Git tab's surface: whichever of the six postures this board is in, and the Board Settings
/// The Git tab's surface: whichever of the four postures this board is in, and the Board Settings
/// door where it applies.
struct BoardGitTabView: View {
let store: BoardStore
/// The tier and the git state this board's **session** composed with, handed down from
/// `BoardInfoView` rather than re-derived 12-editions.md The entitlement ("a lapse never
/// interrupts an open session"). `git` is `@Observable`, so a branch switch or a pause landing
/// while the tab is open repaints it.
let tier: Tier
/// The git state this board's **session** composed with, handed down from `BoardInfoView` rather
/// than re-derived 12-editions.md The entitlement ("a lapse never interrupts an open
/// session"). `git` is `@Observable`, so a branch switch or a pause landing while the tab is open
/// repaints it.
///
/// Optional because a caller with no session in hand (previews, the accessory-installation tests)
/// has none to hand over; a `nil` reads as mode `none`, which is the honest description of a
/// board nothing has detected anything about yet. Since the 2026-08-07 pivot it is no longer a
/// *tier* signal every session composes one (12 PIVOT 2026-08-07).
let git: HistoryStore?
/// The window's settings sheet, so this tab can carry the **Board Settings** row that opens it.
/// `nil` where there is no window to present a sheet on, which reads as a tab with no door.
let settings: BoardSettingsPresentation?
/// The free tier's one question about the board's folder, asked once in `BoardInfoView.init` and
/// passed down the same answer the tab strip's membership was computed from, so the tab and its
/// content can never disagree about which posture this board is in.
let hasGitDirectory: Bool
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s and
/// `BoardThemeTabView`'s own parameter the tab pads by this amount instead of restating the
/// derivation.
@@ -231,18 +178,7 @@ struct BoardGitTabView: View {
/// header's rule.
@ViewBuilder
private var posture: some View {
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
case .absent:
// Unreachable, and kept honest rather than fatal: `BoardInfoTab.available` drops the Git
// tab from the strip on exactly this posture, so nothing ever selects a tab that would
// land here. Written as `EmptyView` because that is what the case *means* if the
// membership rule ever loosened, the free tier's ordinary board would render nothing,
// which is still the design's answer for it (12: "absent, no placeholder").
EmptyView()
case .proPointer:
BoardGitNote()
switch BoardGitSection.resolve(mode: git?.mode ?? .none) {
case .noRepository:
// Nothing daily to show on a board with no repository so the tab is the fact and the
// door. Add-git itself moved to the sheet with the 2026-07-31 split; what stays here is
@@ -285,7 +221,6 @@ struct BoardGitTabView: View {
@ViewBuilder
private var boardSettingsRow: some View {
if let settings, BoardSettingsAvailability.resolve(
tier: tier,
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
) {
+45 -88
View File
@@ -13,8 +13,10 @@ import SwiftUI
/// 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.
/// **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`.
///
/// ### One home, deliberately
///
@@ -62,8 +64,8 @@ extension FocusedValues {
// MARK: - The window-title widget
/// The titlebar widget: the board's name and, on a git-mode Pro board, its branch with a
/// trailing disclosure chevron, whose one job is this popover.
/// The titlebar widget: the board's name and, on a git-mode board, its branch with a trailing
/// disclosure chevron, whose one job is this popover.
///
/// **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
@@ -80,12 +82,14 @@ struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
/// The tier and 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.
let tier: Tier
/// 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
@@ -105,7 +109,6 @@ struct BoardInfoWidget: View {
BoardInfoTitlebarSummary(
snapshotTitle: store.snapshot.title.value,
rootURL: store.rootURL,
tier: tier,
mode: git?.mode ?? .none,
branch: git?.branch
)
@@ -173,7 +176,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, settings: settings)
BoardInfoView(store: store, recents: recents, git: git, settings: settings)
}
}
@@ -212,23 +215,26 @@ 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 under Pro `tier == .pro && mode ==
/// .git` with a non-`nil` branch the same condition family `BoardGitSection.resolve`'s `.branch`
/// case covers. The free tier and an inert `.git` (mode `.none` or `.repoNested`) show 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.** 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).
struct BoardInfoTitlebarSummary: Equatable {
let title: String
let branch: String?
init(snapshotTitle: String?, rootURL: URL, tier: Tier, mode: BoardGitMode, branch: String?) {
init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) {
if let snapshotTitle, !snapshotTitle.isEmpty {
self.title = snapshotTitle
} else {
self.title = rootURL.deletingPathExtension().lastPathComponent
}
self.branch = (tier == .pro && mode == .git) ? branch : nil
self.branch = mode == .git ? branch : nil
}
}
@@ -240,14 +246,13 @@ 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 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.
/// `git` defaults to no session and `settings` to no sheet, 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 no
/// Board Settings row behind it. 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,
settings: BoardSettingsPresentation? = nil
@@ -256,7 +261,6 @@ func boardInfoTitlebarAccessory(
rootView: BoardInfoWidget(
store: store,
recents: recents,
tier: tier,
git: git,
presentation: presentation,
settings: settings
@@ -291,26 +295,16 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
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 }
}
// **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), which is what the filter answered anyway.
}
// MARK: - The popover's content
@@ -326,7 +320,6 @@ struct BoardInfoView: View {
let store: BoardStore
let recents: StyleRecents
let tier: Tier
let git: HistoryStore?
let settings: BoardSettingsPresentation?
@@ -337,17 +330,6 @@ struct BoardInfoView: View {
/// `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 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. 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
/// hand instead of an outer padding that would double up on it **the editor's own figure**
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
@@ -356,36 +338,16 @@ 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,
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)
}
var body: some View {
@@ -432,10 +394,12 @@ 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 `tabs`, not `allCases` membership is
// this board's git posture (`BoardInfoTab.available`).
// 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 all three tabs (03-board-ui.md Board popover, the same-day pivot note).
Picker("Board configuration", selection: $tab) {
ForEach(tabs) { tab in
ForEach(BoardInfoTab.allCases) { tab in
Text(tab.rawValue)
}
}
@@ -452,14 +416,7 @@ struct BoardInfoView: View {
case .theme:
BoardThemeTabView(store: store, inset: inset)
case .git:
BoardGitTabView(
store: store,
tier: tier,
git: git,
settings: settings,
hasGitDirectory: hasGitDirectory,
inset: inset
)
BoardGitTabView(store: store, git: git, settings: settings, inset: inset)
}
}
// The style editor's popover width, taken from the editor rather than restated the number
+2 -2
View File
@@ -57,8 +57,8 @@ struct BoardInfoMetrics: Equatable {
/// gathered in one pass so the four numbers describe the same instant.
///
/// **Taken once per appearance, never live.** The tab measures when it appears and shows that
/// the `hasGitDirectory` posture one section over: a read-only fact refreshed by reopening, not a
/// live subscription. `FolderWatcher` deliberately filters `.git` churn out of the reload stream,
/// a read-only fact refreshed by reopening, not a live subscription. `FolderWatcher` deliberately
/// filters `.git` churn out of the reload stream,
/// so there is no event these numbers could honestly hang off; and a size that ticked while the
/// user watched would be motion without meaning on a settings surface.
struct BoardDiskFootprint: Equatable, Sendable {
+49 -45
View File
@@ -39,51 +39,55 @@ import SwiftUI
/// 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.
/// **It carries the session's 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 fact is adopted once, from the session, at the same
/// moment the titlebar widget adopts it (`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.
///
/// **The tier came out at 12 PIVOT 2026-08-07.** It was adopted here alongside the git state until
/// that day, because the sheet was Pro's; git is tier-independent now, so what the doors validate on
/// is the board's mode alone.
@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.
/// This window's board git state, `nil` on a window whose session has not been adopted yet
/// which reads as mode `none`, the harmless direction (an add-git sheet, offered to a board that
/// may well already have a repository, is nothing anyone can act on before adoption lands).
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
/// Called once per window, from the same place the titlebar widget is handed the same fact.
func adopt(git: HistoryStore?) {
self.git = git
}
/// What the sheet would show right now and therefore, when empty, that there is no sheet to
/// show (`BoardSettingsAvailability`).
///
/// **An unadopted window has none**, and that is a `nil` check rather than a mode reading: every
/// control this sheet hosts writes *through* the git state (`section(_:)` renders nothing without
/// one), so a window that has not been handed its session yet would otherwise offer a sheet of
/// bare headers. It was the free tier's default that kept this shut before 12 PIVOT 2026-08-07;
/// what keeps it shut now is the honest absence of a session, which is the only thing a `nil`
/// ever meant here.
var sections: [BoardSettingsSection] {
BoardSettingsSection.resolve(
tier: tier,
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
guard let git else { return [] }
return BoardSettingsSection.resolve(
mode: git.mode,
isRepositoryUnreadable: git.isRepositoryUnreadable
)
}
/// Both doors' validation: the menu row's `disabled` state and whether the popover shows its row
/// at all.
/// at all. Derived from `sections` rather than from `BoardSettingsAvailability` directly, so the
/// unadopted case above cannot answer one way here and another there.
var isReachable: Bool {
BoardSettingsAvailability.resolve(
tier: tier,
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
)
!sections.isEmpty
}
/// **Opening, not toggling** unlike I. A sheet is modal to its window and carries its own
@@ -118,22 +122,30 @@ extension FocusedValues {
// MARK: - What the sheet holds
/// **The sheet's inventory for one board**, as a pure function of the tier and the mode the shape
/// **The sheet's inventory for one board**, as a pure function of 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.
///
/// **The tier axis came out at 12-editions.md PIVOT 2026-08-07**: this resolved `guard tier ==
/// .pro else { return [] }` first and the mode second until git left the paywall. Every board can
/// reach the setup it has now, and what it has is still the mode's answer the sheet's whole
/// vocabulary is repository-shaped, so a board with nothing repository-shaped to say still hosts
/// nothing.
///
/// 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).
/// Mode `none`: **add-git** (06-history-undo.md Rules Opt-in init) the offer, on every
/// tier since the pivot, and still never an auto-init (git is opt-in per board, 12 PIVOT
/// 2026-08-07).
case git
/// Pro, mode `git`: **branch creation**. Switching stays in the popover (03 Board popover);
/// 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
/// Mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
/// (06 Interaction with external writers).
case commitIdentity
@@ -154,14 +166,9 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
/// `git` every other mode has no repository for the probe to have failed on, and a caller
/// that has no git state to ask is describing one of those boards.
static func resolve(
tier: Tier,
mode: BoardGitMode,
isRepositoryUnreadable: Bool = false
) -> [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]
@@ -204,15 +211,14 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
/// 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`.
/// today's terms that reads: a board whose mode is `none` or `git`, on **any tier** the Pro
/// requirement that stood beside it retired with 12-editions.md PIVOT 2026-08-07.
///
/// 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 unreachable postures are unreachable for reasons that are all the board's, and all the
/// design's `BoardSettingsSection.resolve`'s own comments carry them one by one: **repo-nested**
/// and **unverifiable**, where nothing setup-shaped can apply, and **git mode over a repository that
/// will not open**, where both sections would be writes into something the app cannot open. In each,
/// 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
@@ -221,12 +227,10 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
enum BoardSettingsAvailability {
static func resolve(
tier: Tier,
mode: BoardGitMode,
isRepositoryUnreadable: Bool = false
) -> Bool {
!BoardSettingsSection.resolve(
tier: tier,
mode: mode,
isRepositoryUnreadable: isRepositoryUnreadable
).isEmpty
+2 -1
View File
@@ -44,7 +44,8 @@ extension NSToolbarItem.Identifier {
/// enable exactly when that board has a step to cross and no read-only lock stands
/// (13-native-undo.md Rules). **Every board has undo in every tier**, so there is no tier-shaped
/// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which
/// only a Pro subscription binds.
/// a board binds by being in git mode on any tier since 12-editions.md PIVOT 2026-08-07, and
/// under a Pro subscription before it.
///
/// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the
/// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so