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
+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
) {