diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 9da9330..844b999 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -335,14 +335,20 @@ public final class AppModel { /// **The tier a board session composes under**, as an injectable seam. /// - /// Defaulted to the real entitlement's local read and separated from it for `makeHistoryProvider`'s - /// reason exactly: a test binds a tier without needing a StoreKit transaction, an App Store - /// account, or a second `AppModel` initializer. `@MainActor` on the closure type because the - /// entitlement it reads is main-actor state, and `@ObservationIgnored` because nothing renders - /// from it — the tier reaches the UI, where it reaches it at all, through `entitlement`. + /// Defaulted to the real entitlement's local read and separated from it so a test binds a tier + /// without needing a StoreKit transaction, an App Store account, or a second `AppModel` + /// initializer. `@MainActor` on the closure type because the entitlement it reads is main-actor + /// state, and `@ObservationIgnored` because nothing renders from it — the tier reaches the UI, + /// where it reaches it at all, through `entitlement`. /// /// **Read once per session, at composition, and never again** (12 ▸ The entitlement: "a lapse /// never interrupts an open session"). `beginSession` is the only caller. + /// + /// **Dormant since PIVOT 2026-08-07** (12-editions.md — git left the paywall): the answer is + /// still read and still recorded on the session (`BoardSession.tier`), and **nothing git-shaped + /// consults it any more** — `HistoryStore.compose` and `makeHistoryProvider` both lost the axis. + /// The seam is kept unchanged, mechanics and all, because the entitlement is correct for + /// whatever the next base/Pro split turns out to gate; what it does not do is decide undo. @ObservationIgnored public var currentTier: @MainActor () -> Tier = { .free } @@ -351,12 +357,14 @@ public final class AppModel { /// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a /// board session's undo stack is built by, called once per board as its session begins. /// - /// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12 ▸ The - /// provider seam; 13-native-undo.md's header; 06 ▸ Rules): a board's substrate is decided by what - /// the board *is*, and the tier only decides whether git is on the table at all. The rule it - /// replaced bound the native stack free-tier-wide and nothing at all on Pro's gitless boards, - /// which made subscribing *remove* undo from a mode-none board — an upgrade that takes a feature - /// away. + /// **The provider follows the board — and, since PIVOT 2026-08-07, the board alone** + /// (re-ruled 2026-07-31 — 12 ▸ The provider seam; 13-native-undo.md's header; 06 ▸ Rules; then + /// 12 ▸ PIVOT 2026-08-07, git leaves the paywall). A board's substrate is decided by what the + /// board *is*. The 2026-07-31 re-ruling took the tier out of every row but one — it still decided + /// whether git was on the table at all — and the pivot took it out of that one too: "every tier + /// composes the git stack on git-mode boards exactly as Pro did". The rule the re-ruling replaced + /// bound the native stack free-tier-wide and nothing at all on Pro's gitless boards, which made + /// subscribing *remove* undo from a mode-none board — an upgrade that takes a feature away. /// /// It takes the store because that is what a provider is a history *of*: the git provider needs /// the board root it is a repository at, and the native one's steps are computed from the same @@ -364,21 +372,24 @@ public final class AppModel { /// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because /// nothing renders from it. /// - /// ### The two answers, and the `nil` that is no longer one of them + /// ### The two answers, and the two `nil`s that are no longer among them /// - /// - **No `HistoryStore` at all** — the free tier, where `HistoryStore.compose` returns `nil` - /// without so much as a `stat`: the **native stack, on every board**. "The free tier binds it - /// everywhere (any `.git` inert)" (13), and 12 ▸ The free tier and `.git` names the boards that - /// covers by hand — "a formerly-subscribed user's board, a 1.x board, a repo-nested board … - /// native undo runs". The absent git state *is* the tier test; nothing here reads a flag. - /// - **Mode `git`** (Pro only — no other tier composes a git state) — the git provider: undo as - /// forward restore commits over HEAD's first-parent ancestry (06). - /// - **Mode `none`, `repoNested`, and `unverifiable` alike** — the **native stack**, exactly as - /// in the free tier. "Boards without app-managed git — repo-nested included — bind - /// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog, - /// re-ruled 2026-07-31 twice; 12 ▸ The provider seam; 13's header). `unverifiable` joins the - /// same branch structurally — a denial can never be told apart from a repository actually - /// being there, so it takes `repoNested`'s posture, undo included (06 ▸ Rules ▸ Detection). + /// - **Mode `git`** — the git provider: undo as forward restore commits over HEAD's first-parent + /// ancestry (06). **In every tier** since the pivot; this row used to read "Pro only — no other + /// tier composes a git state", because `HistoryStore.compose` answered `nil` off Pro without so + /// much as a `stat`. + /// - **Mode `none`, `repoNested`, and `unverifiable` alike** — the **native stack**. "Boards + /// without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in + /// **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog, re-ruled 2026-07-31 twice; 12 ▸ The + /// provider seam; 13's header). `unverifiable` joins the same branch structurally — a denial + /// can never be told apart from a repository actually being there, so it takes `repoNested`'s + /// posture, undo included (06 ▸ Rules ▸ Detection). This is where a board nobody opted git in + /// for lands, permanently: opt-in per board is exactly what the pivot left standing (13's + /// header, read through 12 ▸ PIVOT 2026-08-07). + /// - **No `HistoryStore` at all** — the argument stays optional for the seam's own reason (a test + /// binds a git-stateless board through it), and no session composes one: `compose` returns a + /// store for every board there is. It answers the native stack, which is what it always + /// answered for the free tier that used to arrive this way. /// /// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone /// stance "concerns *git*, and this stack never touches git — memory-only, journal-free, @@ -402,7 +413,7 @@ public final class AppModel { /// one event that changes a board's answer under an open session — add-git's commanded flip, /// which swaps `none`'s native stack for `git`'s trail. @ObservationIgnored - public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in + public var makeHistoryProvider: (BoardStore, HistoryStore?) -> (any HistoryProviding)? = { store, git in guard let git else { return NativeHistoryProvider() } switch git.mode { case .git: return GitHistoryProvider(boardRoot: store.rootURL) @@ -429,8 +440,9 @@ public final class AppModel { /// window). It lives here for the store's reason exactly: the session is what every window /// over this board shares, and "undo is board-local". /// - /// Which implementation it is, is the tier's answer and nobody else's - /// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`. + /// Which implementation it is, is the board's answer and nobody else's + /// (12-editions.md ▸ The provider seam, read through ▸ PIVOT 2026-08-07) — see + /// `AppModel.makeHistoryProvider`. /// /// **`nil` is a board with no undo at all, and no board the app composes is one any more** /// (re-ruled 2026-07-31 — see `AppModel.makeHistoryProvider`): boards without app-managed git, @@ -443,33 +455,35 @@ public final class AppModel { /// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single /// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips — /// native out, git in, the in-session steps discarded with it - /// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter. + /// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter, + /// and since PIVOT 2026-08-07 it has no say in this either. public var history: (any HistoryProviding)? - /// **The tier this board composed under** (12-editions.md ▸ The entitlement). + /// **The tier this board composed under** (12-editions.md ▸ The entitlement) — recorded, + /// and **dormant since PIVOT 2026-08-07**. /// - /// A `let`, on a value type, set once by `beginSession` — which is the entire mechanism - /// behind "a lapse never interrupts an open session: an open board finishes with the provider - /// it composed; the next open composes the native stack over inert `.git`". There is no - /// setter, no observation, and nothing anywhere that re-evaluates a live session's tier: a - /// subscription ending mid-session is a fact about the *next* open and about nothing that is - /// already on screen. + /// A `let`, on a value type, set once by `beginSession`. That is the entire mechanism behind + /// "a lapse never interrupts an open session": there is no setter, no observation, and + /// nothing anywhere that re-evaluates a live session's tier, so a subscription ending + /// mid-session is a fact about the *next* open and about nothing already on screen. /// - /// It is recorded rather than merely used-and-discarded because the provider it selects is - /// not the only thing that will ever ask. pro-m1's surfaces — the card window's History - /// section, View ▸ History (12 ▸ Tier matrix) — are per-board questions asked long after - /// composition, and they must get the answer this board actually opened with rather than - /// whatever the entitlement happens to say when the sidebar renders. + /// **Nothing git-shaped reads it.** Git left the paywall (12 ▸ PIVOT 2026-08-07), so the + /// substrate, the committer, the branch controls and the History surfaces are all decided by + /// the board's mode alone. It stays recorded because the entitlement's machinery stays built + /// and correct for whatever the re-decided base/Pro split gates, and because the fact a board + /// opened under is a composition-time answer the way the provider binding is: whatever asks + /// next must get what this board opened with, never what the entitlement says at render time. public let tier: Tier /// **This board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components /// ▸ HistoryStore) — the detected mode, the repository behind it in git mode, and the /// add-git action the popover offers on a board that has none. /// - /// `nil` under the free tier, and that is the inert posture made structural rather than - /// remembered: with no object there is nothing to consult, nothing to detect with, and no - /// path by which a free-tier session could touch `.git` (12-editions.md ▸ The free tier and - /// `.git`). `HistoryStore.compose` is the one place the tier decides it. + /// **Never `nil` on a session the app composes**, since PIVOT 2026-08-07 (12-editions.md): + /// `HistoryStore.compose` runs detection for every board in every tier and returns a store. + /// It used to answer `nil` under the free tier — the inert-`.git` posture made structural + /// rather than remembered — and that posture is retired. The optionality survives for the + /// seam's sake alone: a test binds a git-stateless session through `makeHistoryProvider`. /// /// A `let` beside `tier`, for `tier`'s reason: which board this is a git story *of* is /// settled at composition and cannot change under an open session. What can change is the @@ -477,15 +491,15 @@ public final class AppModel { public let git: HistoryStore? /// The mode this board is being edited in, `none` when there is no git state at all — which - /// is every free-tier session ("The free tier ships exactly one mode: `none`", - /// 12-editions.md ▸ Tier matrix). + /// no session the app composes is any more (see `git` above), and which a board nobody has + /// added git to detects anyway. /// /// **Its first consumer is the provider seam** — `makeHistoryProvider` reads exactly this to - /// know whether the board has a repository to be an undo stack for, and it is the *mode* - /// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider, - /// `none` and `repoNested` alike the native stack. The popover's git section is the other - /// reader — and the one place the two gitless modes still differ, since add-git is offered on - /// one and explained away on the other. + /// know whether the board has a repository to be an undo stack for, and it is the *mode* and + /// nothing else that decides (re-ruled 2026-07-31; the tier's last say went with PIVOT + /// 2026-08-07): `git` binds the git provider, `none` and `repoNested` alike the native stack. + /// The popover's git section is the other reader — and the one place the two gitless modes + /// still differ, since add-git is offered on one and explained away on the other. /// /// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing /// type's isolation, and everything that asks a session what mode it is in is main-actor @@ -886,17 +900,19 @@ public final class AppModel { func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) { // **The entitlement read** (12-editions.md ▸ The entitlement): "Pro state is read from // StoreKit's signed on-device transaction store at board-session composition — the open path - // gains no network dependency." Synchronous, over facts already in memory, on the same line - // as the provider it selects — which is the shape that makes "the open path never waits on - // the App Store" checkable by reading four lines rather than by auditing a call graph. It is - // also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing - // re-derives it. + // gains no network dependency." Synchronous, over facts already in memory. It is the *only* + // time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it. + // + // **Recorded, and consulted by nothing below** (12 ▸ PIVOT 2026-08-07 — git left the + // paywall). This line used to sit on the same line as the git state it gated; the gate is + // gone, and what is left is a dormant fact kept for the base/Pro split still to be ruled. let tier = currentTier() // **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board - // open"), on the same line as the tier that gates it. Under `.free` this returns `nil` - // without looking at the disk at all — the inert posture is unconditional there — and under - // `.pro` it is one `stat` per open, freshly, so a board that gained or lost a `.git` since - // its last open opens in the mode it now has. + // open") — unconditionally, in every tier. One `stat` per open, freshly, so a board that + // gained or lost a `.git` since its last open opens in the mode it now has. Until the + // 2026-08-07 pivot this call took the tier and answered `nil` under `.free`, which bought + // the free tier a *zero*-stat open (the inert-`.git` posture); that promise is retired with + // the posture, and every open now pays what Pro's opens always paid. // // Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The // running session keeps its mode, and the watcher does not scan for `.git` appearing." @@ -911,93 +927,91 @@ public final class AppModel { // callers (the add-git surface, unit tests), and a session that took that default would hand // the committer a ledger nothing ever writes to: every commit this app made would classify // foreign and be authored `Lanework External`. The default is a fallback, never this path's. - let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier, ledger: store.echoes) + let git = HistoryStore.compose(boardRoot: store.rootURL, ledger: store.echoes) // The board's stack is born here, with the session that owns it, and dies in `tearDown` // below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with - // the board session and dies at close/quit ... standard macOS behavior". On Pro's git boards + // the board session and dies at close/quit ... standard macOS behavior". On a git-mode board // it is instead the repository's own trail, which survives everything (06 ▸ Rules ▸ Undo // survives relaunch) — the seam's whole point. - let history = makeHistoryProvider(store, tier, git) + let history = makeHistoryProvider(store, git) // **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal // layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker, // everything else keeps injecting nothing. A *provider* rather than a ranker because each // load wants its own — see `BoardStore.makeIdentityHistoryRanker` — and because add-git // flips the mode mid-session, which this closure picks up for free by asking the git state // at the moment of each load rather than at composition. - if let git { - store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker } - // **The auto-commit engine, wired into the session it commits for** (06-history-undo.md - // ▸ Rules ▸ Auto-commit). Called on every Pro session and not only on git-mode ones, - // because add-git can flip a board mid-session and the committer it builds then must land - // in exactly this shape — `activateAutoCommit` remembers the wiring for that. - git.activateAutoCommit { [weak store] committer in - guard let store else { return } - committer.currentSnapshot = { [weak store] in store?.snapshot } - // **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled - // 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush - // awaits a snapshot generation covering its changed paths before the committer runs — - // the commit's subject can never be outrun by its own reload". Both halves of that - // await are reads of the store the composer is already diffing, which is why they are - // wired here rather than reached for: the engine holds the *policy* (when to wait, how - // long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`). - // - // The generation the gate counts is `landedReloads` — completed *walks* rather than - // applied snapshots — because a value-equal reload skips the assignment and its - // counter since 2026-07-31, and a walk covers a flush's paths whether or not it found - // anything to change (`BoardStore.landedReloads`). - committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() } - committer.landedReloads = { [weak store] in store?.landedReloads } - // 02-architecture.md ▸ Write-failure surfacing, through the strip the board window - // already renders: a genuine commit failure means "your edits are saved, history has - // stopped advancing", which is exactly what the standing suspension row says. Lock - // contention and a held repository never reach here — neither is a failure. - committer.reportFailure = { [weak store] failure in - store?.banners.suspendHistory(reason: failure.message) - } - committer.reportRecovery = { [weak store] in - store?.banners.clearHistorySuspension() - } - // **The corrupt-`.git` loud failure's standing row** (06-history-undo.md ▸ Rules, - // ruled 2026-07-31): a repository the app cannot open pauses the whole git surface - // and says so on the strip, "announced per 10-accessibility.md" — and the same seam - // heals it, since the paused engine's own 15 s re-read is what notices a repository - // repaired in a terminal. Distinct from the suspension above: that row is history - // failing to advance and retrying, this one is there being nothing to advance into. - committer.reportRepositoryUnreadable = { [weak store] unreadable in - store?.noteRepositoryUnreadable(unreadable) - } - store.commitSeam = .binding(to: committer) - // **The undo stack's ear on the committer** — every commit this engine lands, and - // which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry, - // live; ▸ Heal commits are transparent to undo). Bound here rather than in - // `wireGitUndo` because add-git builds a *new* committer, and this wiring is what - // `activateAutoCommit` remembers on its behalf. - committer.reportLanded = { [weak self, ref] window in - guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return } - provider.noteLanded(window) - } + store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker } + // **The auto-commit engine, wired into the session it commits for** (06-history-undo.md + // ▸ Rules ▸ Auto-commit). Called on every session and not only on git-mode ones, because + // add-git can flip a board mid-session and the committer it builds then must land in + // exactly this shape — `activateAutoCommit` remembers the wiring for that. + git.activateAutoCommit { [weak store] committer in + guard let store else { return } + committer.currentSnapshot = { [weak store] in store?.snapshot } + // **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled + // 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush + // awaits a snapshot generation covering its changed paths before the committer runs — + // the commit's subject can never be outrun by its own reload". Both halves of that + // await are reads of the store the composer is already diffing, which is why they are + // wired here rather than reached for: the engine holds the *policy* (when to wait, how + // long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`). + // + // The generation the gate counts is `landedReloads` — completed *walks* rather than + // applied snapshots — because a value-equal reload skips the assignment and its + // counter since 2026-07-31, and a walk covers a flush's paths whether or not it found + // anything to change (`BoardStore.landedReloads`). + committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() } + committer.landedReloads = { [weak store] in store?.landedReloads } + // 02-architecture.md ▸ Write-failure surfacing, through the strip the board window + // already renders: a genuine commit failure means "your edits are saved, history has + // stopped advancing", which is exactly what the standing suspension row says. Lock + // contention and a held repository never reach here — neither is a failure. + committer.reportFailure = { [weak store] failure in + store?.banners.suspendHistory(reason: failure.message) } - // **Add-git swaps the undo substrate too** (06 ▸ Rules ▸ Detection — the one commanded - // mid-session mode flip; 13-native-undo.md's header — "discards the in-session native - // stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`. - git.didAddGit = { [weak self] in - self?.bindHistoryProvider(for: ref) + committer.reportRecovery = { [weak store] in + store?.banners.clearHistorySuspension() } - // **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers, - // ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead - // when that form has been dismissed before the answer arrived — "inline is the primary - // surface, never a silence trap". The banner enumeration is the same one branch switch and - // undo restore post into, one row per failure. - git.reportFailure = { [weak store] failure in - store?.banners.postGitFailure(.addGit, reason: failure.message) + // **The corrupt-`.git` loud failure's standing row** (06-history-undo.md ▸ Rules, + // ruled 2026-07-31): a repository the app cannot open pauses the whole git surface + // and says so on the strip, "announced per 10-accessibility.md" — and the same seam + // heals it, since the paused engine's own 15 s re-read is what notices a repository + // repaired in a terminal. Distinct from the suspension above: that row is history + // failing to advance and retrying, this one is there being nothing to advance into. + committer.reportRepositoryUnreadable = { [weak store] unreadable in + store?.noteRepositoryUnreadable(unreadable) + } + store.commitSeam = .binding(to: committer) + // **The undo stack's ear on the committer** — every commit this engine lands, and + // which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry, + // live; ▸ Heal commits are transparent to undo). Bound here rather than in + // `wireGitUndo` because add-git builds a *new* committer, and this wiring is what + // `activateAutoCommit` remembers on its behalf. + committer.reportLanded = { [weak self, ref] window in + guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return } + provider.noteLanded(window) } - // **The detection-time answer, published once** (06 ▸ Rules: "a standing breakage-class - // banner **at detection**"). The probe ran inside `compose` above — before this session - // existed, and therefore before the seam that carries its transitions was wired — so a - // board that opened into an unreadable repository raises its row here rather than - // waiting for the first debounce to rediscover what composition already knows. - store.noteRepositoryUnreadable(git.isRepositoryUnreadable) } + // **Add-git swaps the undo substrate too** (06 ▸ Rules ▸ Detection — the one commanded + // mid-session mode flip; 13-native-undo.md's header — "discards the in-session native + // stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`. + git.didAddGit = { [weak self] in + self?.bindHistoryProvider(for: ref) + } + // **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers, + // ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead + // when that form has been dismissed before the answer arrived — "inline is the primary + // surface, never a silence trap". The banner enumeration is the same one branch switch and + // undo restore post into, one row per failure. + git.reportFailure = { [weak store] failure in + store?.banners.postGitFailure(.addGit, reason: failure.message) + } + // **The detection-time answer, published once** (06 ▸ Rules: "a standing breakage-class + // banner **at detection**"). The probe ran inside `compose` above — before this session + // existed, and therefore before the seam that carries its transitions was wired — so a + // board that opened into an unreadable repository raises its row here rather than + // waiting for the first debounce to rediscover what composition already knows. + store.noteRepositoryUnreadable(git.isRepositoryUnreadable) // **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the // store is that boundary — every app-mediated mutation goes out through one of its write // methods — so it is the store that computes each inverse and registers it. What it cannot @@ -1291,15 +1305,17 @@ public final class AppModel { /// A judgment call, recorded when the free-tier matrix still left this board with no provider at /// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit` /// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with - /// the provider it composed" is a rule about a **tier** lapsing, which cannot change a running - /// session at all; and a board that visibly starts accumulating commits while ⌘Z answers from a + /// the provider it composed" was a rule about a **tier** lapsing, which never could change a + /// running session — and since PIVOT 2026-08-07 no longer changes the *next* open either, since + /// a git board composes the git provider in every tier; and a board that visibly starts + /// accumulating commits while ⌘Z answers from a /// stack the repository knows nothing about would read as a defect rather than as a policy. /// /// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but /// `none`, and flips to `.git` before it fires `didAddGit`. func bindHistoryProvider(for ref: BoardWindowRef) { guard var session = sessions[ref], let git = session.git, git.mode == .git else { return } - guard let history = makeHistoryProvider(session.store, session.tier, git) else { return } + guard let history = makeHistoryProvider(session.store, git) else { return } guard history !== session.history else { return } // Before the reassignment, while `session.history` is still the substrate being replaced: the // in-flight native steps go with it, and any closure that outlives this line finds an empty @@ -1351,8 +1367,9 @@ public final class AppModel { /// /// This is the honest seam between the two halves of the rule: the host knows a window exists, the /// committer knows what staging is, and only the app model knows which board a card window belongs - /// to and how to reach its committer. A board with no committer — the free tier, a Pro board with - /// no repository — records nothing, which is the same `nil` every other git seam takes. + /// to and how to reach its committer. A board with no committer — any board nobody has added git + /// to, which since PIVOT 2026-08-07 is the only such board there is (12-editions.md) — records + /// nothing, which is the same `nil` every other git seam takes. /// /// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be /// moved into the trash, in the middle of a session, and what must be staged around is wherever @@ -1693,8 +1710,9 @@ public final class AppModel { // pending auto-commit — before teardown; nothing settled is ever left unsaved or // uncommitted by closing"). By the time it runs, step 1 has ended every card window's // session *and released its stage-around*, so each session — body, comments, purge and - // all — lands in exactly one commit. `nil` on every board with no committer, which is the - // whole free tier. + // all — lands in exactly one commit. `nil` on every board with no committer, which is + // every board nobody has added git to (12 ▸ PIVOT 2026-08-07 — the tier no longer + // decides; opt-in per board still does). committerFlush: { [weak self] in await self?.sessions[ref]?.git?.committer?.flushNow() }, diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index a0fe7e2..de0982a 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -113,8 +113,9 @@ struct BoardWindowHost: View { /// following walk builds (`BoardRepairRun`, `EchoLedger.adopt`). /// /// It cannot live anywhere else: the repairs run before a store exists and the receipts have to - /// reach that store's ledger before `beginSession` composes Pro's committer, or the app's own - /// repair commits as `Lanework External`. Cleared once adopted. + /// reach that store's ledger before `beginSession` composes a git board's committer, or the app's + /// own repair commits as `Lanework External`. (Pro's committer, until 12-editions.md ▸ PIVOT + /// 2026-08-07 made it every git board's.) Cleared once adopted. @State private var repairLedger: EchoLedger? @State private var phase: Phase = .opening @@ -396,7 +397,8 @@ struct BoardWindowHost: View { // **The repair's receipts, into the board's own ledger — before the session composes** // (01: "On Pro boards the repairs drop heal-marked receipts and commit separately as one - // repair commit"). `beginSession` is where Pro's committer is built and started, and the + // repair commit" — every git board's since 12-editions.md ▸ PIVOT 2026-08-07, the sentence + // otherwise unchanged). `beginSession` is where that committer is built and started, and the // committer harvests the ledger it is handed; receipts adopted after that line would be // receipts the repair commit never sees, and the app's own repair would be authored // `Lanework External`. @@ -730,31 +732,34 @@ struct BoardWindowHost: View { // load rather than at attach because it carries the store; the controller installs it once, // whichever of the two arrives second. // - // The tier and the git state come from the **session**, which `start()` began a moment ago, - // rather than from the entitlement or the disk: a board's popover must describe the board as - // it opened (12-editions.md ▸ The entitlement, "an open board finishes with the provider it - // composed"; 06-history-undo.md ▸ Rules, mode is an open-time fact). A `nil` session cannot - // happen on this path — `beginSession` precedes `configureWindow` — and reads as the free - // tier's posture, which is the harmless direction. + // The git state comes from the **session**, which `start()` began a moment ago, rather than + // from the entitlement or the disk: a board's popover must describe the board as it opened + // (12-editions.md ▸ The entitlement, "an open board finishes with the provider it composed"; + // 06-history-undo.md ▸ Rules, mode is an open-time fact). A `nil` session cannot happen on + // this path — `beginSession` precedes `configureWindow` — and reads as a board with no + // repository, which is the harmless direction. + // + // **The tier is no longer passed down** (12 ▸ PIVOT 2026-08-07): git is tier-independent, so + // every one of these surfaces reads the board's mode and nothing else. `BoardSession.tier` + // still exists and is still recorded — it just has no git-facing consumer here. let session = appModel.session(for: ref) - // The settings sheet's two doors validate on the same pair, so they are adopted here rather + // The settings sheet's two doors validate on the same fact, so it is adopted here rather // than read again somewhere else: the popover's Board Settings… row and Board ▸ Board // Settings… must never disagree about whether this board has setup to show // (`BoardSettingsAvailability`). The mode *inside* the git state stays live — add-git flipping // it re-resolves the sheet's sections and both doors, which is the one mid-session transition // 06 sanctions. - boardSettings.adopt(tier: session?.tier ?? .free, git: session?.git) + boardSettings.adopt(git: session?.git) windowController.installTitlebarAccessory( boardInfoTitlebarAccessory( store: store, recents: appModel.styleRecents, - tier: session?.tier ?? .free, git: session?.git, presentation: boardInfo, settings: boardSettings ) ) - // The widget above now says the board's name (and, on a git-mode Pro board, its branch) + // The widget above now says the board's name (and, on a git-mode board, its branch) // itself, so the system title display would only repeat it — the card-window seam // (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for // the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched — diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index accbd12..4f5fc20 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -213,7 +213,9 @@ final class CardWindowSession: CardSessionFlushing { // net effect, and the `comments/.trash/` purge, which must not run while a board step's undo // still restores comments out of it. Registering answers whether the step took the purge on — // and a board whose substrate keeps no steps has already run it by the time that answer comes - // back, which is how Pro keeps purging at the close flush without a word about tiers here. + // back, which is how a git board keeps purging at the close flush without a word about tiers + // here (and there is none to say since 12-editions.md ▸ PIVOT 2026-08-07 — the substrate is + // the board's mode, not a subscription). // // **This window stops owning its card's comment trash first** (ruled 2026-08-06 — the // open-window carve-out). While the window is open the store defers every purge of this @@ -418,9 +420,11 @@ struct CardWindowHost: View { /// **This card's commit trail, or nothing at all** (05-card-window.md ▸ History). /// /// `nil` is the section's absence rule, read from the board's own git state rather than from a - /// flag: no `HistoryStore` means the free tier (12-editions.md — where the section never exists), - /// and a mode other than `git` means a board the app manages no history for. The object is held - /// by this host so it survives every snapshot, `thumbnails`' reason exactly. + /// flag: a mode other than `git` means a board the app manages no history for, on any tier. The + /// question stopped having a tier half at 12-editions.md ▸ PIVOT 2026-08-07 — every session + /// composes a git state now, so what decides the section is whether *this board's* history is + /// git-backed. The object is held by this host so it survives every snapshot, `thumbnails`' + /// reason exactly. private var cardHistory: CardHistory? { guard appModel.session(for: ref.board)?.gitMode == .git else { return nil } return history diff --git a/Kanban/Git/BoardGitMode.swift b/Kanban/Git/BoardGitMode.swift index 079018f..e1fbd89 100644 --- a/Kanban/Git/BoardGitMode.swift +++ b/Kanban/Git/BoardGitMode.swift @@ -39,8 +39,10 @@ import Foundation public enum BoardGitMode: String, Sendable, Equatable, CaseIterable { /// No `.git` at the board root and none above it — **every** ancestor check answered not-found, - /// "clean none" in 06's own words. The only mode the free tier ships (12-editions.md ▸ Tier - /// matrix), the one add-git moves a board out of, and — now that this axis exists — the one mode + /// "clean none" in 06's own words. The mode of every board nobody has opted into git for — which + /// is what a board without app-managed git *is* now that the tier axis is gone (12-editions.md + /// ▸ PIVOT 2026-08-07; it used to be the only mode the free tier shipped, over the retired + /// inert-`.git` posture). The one add-git moves a board out of, and — now that this axis exists — the one mode /// add-git's own re-detection requires before it will act: a raced or stale read that turns out /// to be `.unverifiable` or `.repoNested` refuses the init exactly as those modes always did. case none diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index 135dd29..fcd7f61 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -44,12 +44,15 @@ public struct GitLandedWindow: Sendable, Equatable { /// **Every settled change becomes a commit** (06-history-undo.md ▸ Rules ▸ Auto-commit), debounced /// past drag and typing churn, on git-mode boards and nowhere else. /// -/// ### Structurally unreachable off Pro +/// ### Structurally unreachable on a board with no repository /// -/// One of these exists per `HistoryStore` in mode `git`, and a `HistoryStore` exists only under Pro -/// (`HistoryStore.compose` is the tier gate). The free tier therefore has no committer to disable, -/// no debounce to cancel and no `.git` to touch — 12-editions.md's inert posture as a shape rather -/// than as a flag, which `InertGitTests` pins against real bytes. +/// One of these exists per `HistoryStore` in mode `git` and nowhere else. Until the 2026-08-07 pivot +/// there was a second gate above it — a `HistoryStore` existed only under Pro — and that one is gone +/// (12-editions.md ▸ PIVOT 2026-08-07: git left the paywall, and every tier composes the git stack +/// on git-mode boards). What remains is the stronger of the two anyway, because it never depended on +/// a subscription: git is **opt-in per board** (06 ▸ Rules), so a board the user never added git to +/// detects `none`, composes no committer, and has no debounce to cancel and no `.git` to touch. The +/// file layer's own indifference to `.git` is pinned against real bytes by `UntouchedGitTests`. /// /// ### What arms it /// diff --git a/Kanban/Git/GitHistoryProvider.swift b/Kanban/Git/GitHistoryProvider.swift index d285c29..7f4eb0e 100644 --- a/Kanban/Git/GitHistoryProvider.swift +++ b/Kanban/Git/GitHistoryProvider.swift @@ -254,13 +254,15 @@ public final class GitHistoryProvider: HistoryProviding { // MARK: - HistoryProviding /// **Deliberately nothing — except the one thing a dropped step is owed.** On a git board an undo - /// step is a commit, and the Writer boundary's inverse operations are the *free* tier's substrate - /// (13-native-undo.md). `BoardStore` registers against whatever provider the session bound, and + /// step is a commit, and the Writer boundary's inverse operations are the *gitless* board's + /// substrate (13-native-undo.md — the tier axis it once read as went with 12-editions.md + /// ▸ PIVOT 2026-08-07; the substrate is the board's mode alone). `BoardStore` registers against + /// whatever provider the session bound, and /// this one has a repository to read instead — so the registrations arrive and are dropped, which /// is exactly what "the commit trail itself is the substrate" (14 ▸ C1) means in code. /// /// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the - /// tier split in 13's purge rule structural rather than conditional: "on Pro the substrate is + /// substrate split in 13's purge rule structural rather than conditional: "on Pro the substrate is /// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge /// rides the close flush there as before" (13 ▸ Interaction with the trash). A card window's close /// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs diff --git a/Kanban/Git/GitHousekeeping.swift b/Kanban/Git/GitHousekeeping.swift index dacfebf..460c825 100644 --- a/Kanban/Git/GitHousekeeping.swift +++ b/Kanban/Git/GitHousekeeping.swift @@ -377,12 +377,12 @@ enum GitHousekeeping { /// **When a housekeeping pass runs** (06-history-undo.md ▸ Repository hygiene) — one per git-mode /// board session, scheduled at board open and never again. /// -/// ### Structurally unreachable off Pro +/// ### Structurally unreachable on a board with no repository /// -/// One of these exists per `HistoryStore` in mode `git`, and a `HistoryStore` exists only under Pro -/// (`HistoryStore.compose` is the tier gate) — `GitAutoCommitter`'s rule, for its reason. The free -/// tier has no housekeeper to disable and no `.git` to pack (12-editions.md ▸ The free tier and -/// `.git`, which `InertGitTests` pins against real bytes). +/// One of these exists per `HistoryStore` in mode `git` and nowhere else — `GitAutoCommitter`'s +/// rule, for its reason, including the tier gate that used to sit above it and no longer does +/// (12-editions.md ▸ PIVOT 2026-08-07). A board the user never added git to detects `none`, so there +/// is no housekeeper to disable and no `.git` to pack. /// /// ### Off the open path, on purpose /// diff --git a/Kanban/Git/HistoryCommitSeam.swift b/Kanban/Git/HistoryCommitSeam.swift index de79fae..6e9cc59 100644 --- a/Kanban/Git/HistoryCommitSeam.swift +++ b/Kanban/Git/HistoryCommitSeam.swift @@ -7,9 +7,11 @@ import Foundation /// /// ### Why a struct of closures rather than a reference to the committer /// -/// `BoardStore` lives in the live store and must not learn what a repository is: the free tier -/// composes no `HistoryStore`, so the engine has to be *structurally* unreachable there rather than -/// switched off, and a store holding an optional committer would be a store that knows about git. +/// `BoardStore` lives in the live store and must not learn what a repository is: most boards have no +/// committer at all — git is opt-in per board (06 ▸ Rules), and since the 2026-08-07 pivot that is +/// the *only* reason a board lacks one (12-editions.md) — so the engine has to be *structurally* +/// unreachable there rather than switched off, and a store holding an optional committer would be a +/// store that knows about git. /// One optional value, `nil` on every board that has no committer, is the same shape `watcherBrackets` /// and `history` already take, and it keeps the three orderings — before the write, after the /// bracket, after the landing — stated in one type instead of three properties that could drift. diff --git a/Kanban/Git/HistoryStore.swift b/Kanban/Git/HistoryStore.swift index ba1fabf..780a662 100644 --- a/Kanban/Git/HistoryStore.swift +++ b/Kanban/Git/HistoryStore.swift @@ -7,19 +7,25 @@ import os /// opened in, the repository behind it when there is one, and the two operations that can change /// either — the app's own add-git, and nothing else. /// -/// ### One per board session, composed under the tier +/// ### One per board session, composed at every open — in every tier /// -/// `compose(boardRoot:tier:)` is the whole gate: **the free tier gets no `HistoryStore` at all**, so -/// a free-tier session runs no detection, opens no repository, and does not so much as `stat` a -/// `.git` — "any `.git` is inert … the app never reads history, never commits, never touches `.git` -/// in any way" (12-editions.md ▸ The free tier and `.git`), which `InertGitTests` pins against real -/// bytes. Nothing in this type is conditional on a tier, because the tier decided whether the type -/// exists. +/// `compose(boardRoot:ledger:)` runs detection and hands back a store for **every** session +/// (12-editions.md ▸ PIVOT 2026-08-07 — git left the paywall: "every tier composes the git stack on +/// git-mode boards exactly as Pro did"). It used to be the gate: the free tier got no object at all, +/// so a free session ran no detection and did not so much as `stat` a `.git` — the inert-`.git` +/// posture made structural rather than remembered. That posture is **retired**. A `.git` at a board +/// root is live in every tier, detection runs at every board open off the same path Pro's always +/// used, and nothing in this type ever asks what anybody paid. +/// +/// What the pivot does **not** change is why mode `none` still exists at all: git stays **opt-in per +/// board** (06 ▸ Rules — "No silent auto-init, ever"). A board whose user never asked for a +/// repository composes here, detects `none`, and builds no committer, no switcher and no +/// housekeeper — nothing that could touch a `.git` it does not have. /// /// ### What it does not do yet /// /// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history -/// ranker. **The provider binding reads `mode` and nothing else about a tier** — the composition +/// ranker. **The provider binding reads `mode` and nothing else** — the composition /// root binds the git provider on mode `git` and the native stack on modes `none` and `repoNested` /// alike (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the board, and /// what a repo-nested board denies is app-managed history, never ⌘Z). Auto-commit, commit messages, @@ -82,9 +88,11 @@ public final class HistoryStore { /// **The auto-commit engine** (06-history-undo.md ▸ Rules ▸ Auto-commit), or `nil` on a board /// there is no repository to commit into. /// - /// Its existence is exactly `mode == .git`, and that invariant is the tier gate one level down: - /// no `HistoryStore` off Pro means no committer anywhere off Pro, with nothing to disable and no - /// flag to forget. + /// Its existence is exactly `mode == .git`, and since the 2026-08-07 pivot (12-editions.md) that + /// invariant carries the whole story on its own: what keeps a committer off a board is the + /// board's own mode — git is opt-in per board, so a user who never asked for a repository has + /// nothing here to disable and no flag anyone could forget. It used to rest on a tier gate one + /// level up (no `HistoryStore` off Pro meant no committer off Pro); the gate is gone. /// /// **Composed inert and started separately.** Composition happens on the board-open path, where /// nothing may block and where a session does not exist yet; `activateAutoCommit(_:)` is what @@ -256,14 +264,19 @@ public final class HistoryStore { housekeeper.schedule() } - /// **The tier gate and the open-time detection, in one line** (12-editions.md ▸ The provider - /// seam; 06-history-undo.md ▸ Rules ▸ Detection) — called by `AppModel.beginSession` beside the - /// entitlement read that supplies `tier`. + /// **The open-time detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board + /// open") — called by `AppModel.beginSession`, unconditionally, once per board. /// - /// `nil` under `.free` means exactly what it says: no git state exists for that session, so no - /// caller can accidentally consult one. Under `.pro` the mode is whatever the filesystem says - /// right now, and a board that has changed mode since its last open simply opens in the new one - /// — "the app just reflects what it finds". + /// ### This was the tier gate, and is not one any more + /// + /// It took a `tier` and answered `nil` under `.free`: no git state existed for such a session, so + /// no caller could consult one and no free open ever stat'ed a `.git`. **PIVOT 2026-08-07** + /// (12-editions.md) retired that whole axis — "detection runs at every board open" in every tier + /// — so the parameter is *removed* rather than ignored, and the zero-stat promise dies with it: + /// every open now pays the same handful of `stat`s Pro's opens always paid, off this same path. + /// + /// The mode is whatever the filesystem says right now, and a board that has changed mode since + /// its last open simply opens in the new one — "the app just reflects what it finds". /// /// **Adoption needs no step of its own**: a board whose root already carries `.git` lands in /// `.git` here, silently, with no dialog and nothing to confirm — "the repo's presence *is* the @@ -274,8 +287,7 @@ public final class HistoryStore { /// store-less `HistoryStore` still composes: an empty ledger vouches for nothing, which is the /// honest answer for a git state with no session behind it (everything reads foreign, the /// launch-catch-up doctrine). - public static func compose(boardRoot: URL, tier: Tier, ledger: EchoLedger = EchoLedger()) -> HistoryStore? { - guard tier == .pro else { return nil } + public static func compose(boardRoot: URL, ledger: EchoLedger = EchoLedger()) -> HistoryStore { let mode = BoardGitMode.detect(boardRoot: boardRoot) logger.debug("board opened in git mode \(mode.rawValue, privacy: .public)") return HistoryStore(boardRoot: boardRoot, mode: mode, ledger: ledger) @@ -286,10 +298,11 @@ public final class HistoryStore { /// **Opt-in init** (06-history-undo.md ▸ Rules): initializes a repository at the board root and /// immediately commits the whole tree as "Initial board state". /// - /// Reachable from one place — the board settings sheet's Git section under Pro — and from nowhere - /// else: + /// Reachable from one place — the board settings sheet's Git section, in every tier since the + /// 2026-08-07 pivot (12-editions.md) — and from nowhere else: /// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo - /// under every board it opened. + /// under every board it opened. Opt-in is what the pivot deliberately left standing: git leaving + /// the paywall widened *who* may ask, never *whether* asking is required. /// /// **It flips the open board's mode immediately**, which is the design's one sanctioned /// mid-session transition: "clicking it flips the open board into git mode immediately — the @@ -419,8 +432,8 @@ public final class HistoryStore { // MARK: - The loader's history seam /// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md ▸ Fractal layout ▸ Rules; - /// `BoardLoader.IdentityHistoryRanker`), or `nil` on any board the app manages no git for — the - /// free tier and modes `none`/`repoNested` alike, all of which fall through to the ladder's + /// `BoardLoader.IdentityHistoryRanker`), or `nil` on any board the app manages no git for — + /// modes `none`, `repoNested` and `unverifiable` alike, all of which fall through to the ladder's /// remaining rungs (birth date, then traversal order). /// /// **A fresh ranker per ask, deliberately.** Each one computes its map at most once, lazily, and diff --git a/Kanban/History/CardWindowUndo.swift b/Kanban/History/CardWindowUndo.swift index cd1cdad..883ad2f 100644 --- a/Kanban/History/CardWindowUndo.swift +++ b/Kanban/History/CardWindowUndo.swift @@ -13,11 +13,12 @@ import Foundation /// /// ### Two jobs, and the second is why this is a type rather than a stored provider /// -/// **The fine stack**: an ordinary `NativeHistoryProvider`, in *both* tiers. The steps a card window +/// **The fine stack**: an ordinary `NativeHistoryProvider`, on *every* board. The steps a card window /// registers are values-based inverses at the Writer boundary — the same shape whatever substrate the -/// board's own history has — so a Pro git board's card window still walks its own gestures with the -/// native grammar, and only the *coarse* close unit is tier-split ("one native board step, or one -/// commit" — 06-history-undo.md ▸ Undo routing). +/// board's own history has — so a git board's card window still walks its own gestures with the +/// native grammar, and only the *coarse* close unit splits by substrate ("one native board step, or +/// one commit" — 06-history-undo.md ▸ Undo routing). The split was written as a tier split and is a +/// board split since 12-editions.md ▸ PIVOT 2026-08-07; nothing in this type ever read either. /// /// **The fold**: window close registers "one coarse step ... whose undo restores the card subtree to /// its session-start state ... and whose redo reapplies the net effect". That net effect is exactly diff --git a/Kanban/History/HistoryProviding.swift b/Kanban/History/HistoryProviding.swift index f4ac7d5..7fa7a7a 100644 --- a/Kanban/History/HistoryProviding.swift +++ b/Kanban/History/HistoryProviding.swift @@ -262,21 +262,25 @@ public struct HistoryStep { /// seam from a window is the one coarse step its close registers. /// /// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds -/// which implementation it gets — **following the board, not the tier alone** (re-ruled 2026-07-31): -/// a board without app-managed git — repo-nested included (re-ruled 2026-07-31) — binds -/// `NativeHistoryProvider` (two step stacks over the inverses registered at the Writer boundary) in -/// every tier, a Pro git board binds the git provider (undo as forward restore commits over HEAD's -/// first-parent ancestry — 06-history-undo.md), and Teams inherits Pro's. +/// which implementation it gets — **following the board, and since the 2026-08-07 pivot the board +/// alone** (re-ruled 2026-07-31; 12-editions.md ▸ PIVOT 2026-08-07): a board without app-managed git +/// — repo-nested included (re-ruled 2026-07-31) — binds `NativeHistoryProvider` (two step stacks over +/// the inverses registered at the Writer boundary), and a git-mode board binds the git provider (undo +/// as forward restore commits over HEAD's first-parent ancestry — 06-history-undo.md). Both answers +/// are the same in every tier; the tier used to decide whether git was on the table at all, and no +/// longer does. /// /// ### What this protocol deliberately does not say /// /// - **No `NSUndoManager`, anywhere in the signature.** It is the native provider's implementation /// detail, and a seam that vended one would be a seam only one provider could ever satisfy — the /// opposite of the reason the seam exists at all ("the free tier's native undo is the first proof -/// the seam is real", 12). AppKit still needs an `UndoManager` to hand the responder chain; that -/// adapter is `BoardUndoManager`, which sits *over* this protocol rather than inside it. -/// - **No persistence promise.** The native stack dies with the session (13); Pro's survives -/// relaunch because git does (06). Both are honest implementations of these seven members. +/// the seam is real", 12 — written when native undo was the free tier's; the proof it names is +/// two working substrates, which the pivot leaves standing). AppKit still needs an `UndoManager` +/// to hand the responder chain; that adapter is `BoardUndoManager`, which sits *over* this +/// protocol rather than inside it. +/// - **No persistence promise.** The native stack dies with the session (13); the git provider's +/// survives relaunch because git does (06). Both are honest implementations of these seven members. /// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's /// (06 ▸ Undo routing, tier-independent) — `BoardUndoRouting`. /// diff --git a/Kanban/History/NativeHistoryProvider.swift b/Kanban/History/NativeHistoryProvider.swift index e306786..c8a6193 100644 --- a/Kanban/History/NativeHistoryProvider.swift +++ b/Kanban/History/NativeHistoryProvider.swift @@ -2,8 +2,10 @@ import Foundation // MARK: - NativeHistoryProvider -/// The free tier's undo substrate: one stack per board session (13-native-undo.md) — **and the -/// substrate of every open card window's stack, in either tier** (re-ruled 2026-07-31, the +/// The undo substrate of every board without app-managed git: one stack per board session +/// (13-native-undo.md, whose header lost its tier axis with the 2026-08-07 pivot — the substrate is +/// the board's mode alone, and git is opt-in per board, so a board nobody opted in keeps this stack +/// for good) — **and the substrate of every open card window's stack, on any board** (re-ruled 2026-07-31, the /// session-coarsening model): a window's fine-grained gestures are values-based inverses whatever the /// board's own substrate is, so `CardWindowUndo` holds one of these too. Nothing below knows which of /// the two it is; both need the same four-line grammar. diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index 25f76a2..839de90 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -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 diff --git a/Kanban/UI/Board/BoardGitTabView.swift b/Kanban/UI/Board/BoardGitTabView.swift index b9c1e94..50496de 100644 --- a/Kanban/UI/Board/BoardGitTabView.swift +++ b/Kanban/UI/Board/BoardGitTabView.swift @@ -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 ) { diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index 015310a..5ffd442 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -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 diff --git a/Kanban/UI/Board/BoardInfoTabView.swift b/Kanban/UI/Board/BoardInfoTabView.swift index dc920fa..8993439 100644 --- a/Kanban/UI/Board/BoardInfoTabView.swift +++ b/Kanban/UI/Board/BoardInfoTabView.swift @@ -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 { diff --git a/Kanban/UI/Board/BoardSettingsSheet.swift b/Kanban/UI/Board/BoardSettingsSheet.swift index fd2385d..3f2eb76 100644 --- a/Kanban/UI/Board/BoardSettingsSheet.swift +++ b/Kanban/UI/Board/BoardSettingsSheet.swift @@ -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 diff --git a/Kanban/UI/Board/BoardToolbar.swift b/Kanban/UI/Board/BoardToolbar.swift index 2d31ded..acf5015 100644 --- a/Kanban/UI/Board/BoardToolbar.swift +++ b/Kanban/UI/Board/BoardToolbar.swift @@ -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 diff --git a/Kanban/UI/Card/CardHistorySection.swift b/Kanban/UI/Card/CardHistorySection.swift index 39e3425..446157c 100644 --- a/Kanban/UI/Card/CardHistorySection.swift +++ b/Kanban/UI/Card/CardHistorySection.swift @@ -83,9 +83,15 @@ enum CardHistoryRows { /// ### Its existence is the section's visibility rule /// /// "The section is **absent** on boards without app-managed git (mode none, repo-nested) — same -/// honesty rule as the popover's git section" (05 ▸ History), and the free tier has no git state at -/// all (12-editions.md). So the host builds one of these only in mode `git`, and `nil` is the whole -/// of the absence — no placeholder, no empty header, nothing to explain. +/// honesty rule as the popover's git section" (05 ▸ History). So the host builds one of these only +/// where the board's history is genuinely git-backed — its `HistoryStore` in mode `git` — and `nil` +/// is the whole of the absence: no placeholder, no empty header, nothing to explain. +/// +/// **Per board, never per tier** (12-editions.md ▸ PIVOT 2026-08-07). 12's old tier matrix listed +/// the card History sidebar as a Pro row, so the section's absence used to have two causes at once — +/// a gitless board, or a free-tier session with no git state to ask. Git is tier-independent now: +/// the one question left is whether *this board* has a repository the app manages, which is the +/// question 05 was always asking. /// /// ### It re-reads rather than subscribes /// diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index e627723..fcddaa6 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -97,8 +97,8 @@ struct CardWindowView: View { /// the close can fold it, which is why it arrives here rather than being made here. let undo: CardWindowUndo /// **This card's commit trail** (05 ▸ History), or `nil` on every board with no app-managed git — - /// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule; - /// see `historySlot`. + /// mode none, repo-nested, unverifiable. The `nil` *is* the section's absence rule; see + /// `historySlot`. A per-board question on every tier since 12-editions.md ▸ PIVOT 2026-08-07. let history: CardHistory? /// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole /// window**"). `nil` only where a caller has no store to import through. @@ -338,10 +338,14 @@ struct CardWindowView: View { /// commit trail, read-only … newest first — semantic subject, relative date, author"). /// /// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without - /// app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section", - /// and the free tier has no git state at all (12-editions.md ▸ The free tier and `.git`). The host - /// builds a `CardHistory` only in mode `git`, so there is no placeholder here to decide about: - /// what the slot reserves is the **position**, and on every other board that position is empty. + /// app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section". + /// The host builds a `CardHistory` only where the board's history is git-backed (mode `git`), so + /// there is no placeholder here to decide about: what the slot reserves is the **position**, and + /// on every other board that position is empty. + /// + /// **The tier is not one of the inputs** (12-editions.md ▸ PIVOT 2026-08-07 — git left the + /// paywall, retiring 12's tier matrix row that made this a Pro surface): a git board shows its + /// trail on any tier, a gitless one shows nothing on any tier. /// // A later card: View ▸ History, which focuses this section (11-command-nexus.md). @ViewBuilder diff --git a/KanbanTests/AutoCommitTests.swift b/KanbanTests/AutoCommitTests.swift index 1dc20fc..c6b772f 100644 --- a/KanbanTests/AutoCommitTests.swift +++ b/KanbanTests/AutoCommitTests.swift @@ -32,7 +32,7 @@ private func makeBoard() throws -> WriterFixture { private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) { let fixture = try makeBoard() let ledger = EchoLedger() - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: ledger)) + let git = HistoryStore.compose(boardRoot: fixture.root, ledger: ledger) #expect(await git.addGit()) return (fixture, git, ledger) } @@ -805,7 +805,7 @@ struct AutoCommitContentionTests { encoding: .utf8 ) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .git) let committer = try quickCommitter(git) @@ -902,29 +902,33 @@ struct FlushBeforeOverwriteTests { } } -// MARK: - Composition and the tier gate +// MARK: - Composition @MainActor @Suite("Auto-commit ▸ composition") struct AutoCommitCompositionTests { - @Test("The free tier composes no committer, because it composes no git state at all") - func theFreeTierHasNoCommitter() throws { + @Test("A board carrying a `.git` composes a committer — there is no tier to compose under") + func aGitBearingBoardComposesACommitter() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) - // The tier gate is one level up: no `HistoryStore` means no committer, nothing to disable, - // and no path by which a free-tier session could touch `.git` (12-editions.md). - #expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil) + // **PIVOT 2026-08-07** (12-editions.md — git left the paywall). This test used to assert the + // gate one level up: `compose(tier: .free)` was `nil`, so there was no committer, nothing to + // disable and no path by which a free session could touch `.git`. What decides now is the + // board's own mode, and this board has a `.git` at its root. + let git = HistoryStore.compose(boardRoot: fixture.root) + #expect(git.mode == .git) + #expect(git.committer != nil, "the committer's existence is exactly mode == .git") } - @Test("A Pro board without a repository has no committer either") + @Test("A board without a repository has no committer") func modeNoneHasNoCommitter() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .none) #expect(git.committer == nil, "the committer's existence is exactly mode == .git") } @@ -934,7 +938,7 @@ struct AutoCommitCompositionTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) var wired = 0 git.activateAutoCommit { _ in wired += 1 } #expect(git.committer == nil) @@ -949,7 +953,7 @@ struct AutoCommitCompositionTests { let (fixture, _, _) = try await makeGitBoard() defer { fixture.tearDown() } - let reopened = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let reopened = HistoryStore.compose(boardRoot: fixture.root) let committer = try #require(reopened.committer) // Composition happens on the board-open path, where arming a debounce would be a side effect // of *detection*. `activateAutoCommit` is what starts it. diff --git a/KanbanTests/BoardDecisionSurfaceTests.swift b/KanbanTests/BoardDecisionSurfaceTests.swift index 767b5b5..fad034f 100644 --- a/KanbanTests/BoardDecisionSurfaceTests.swift +++ b/KanbanTests/BoardDecisionSurfaceTests.swift @@ -719,7 +719,7 @@ struct BoardDecisionSurfaceRepairCommitTests { // The repository, with everything as it stands committed — including the broken root, which is // what makes the repair a real change rather than a fresh file. - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: EchoLedger())) + let git = HistoryStore.compose(boardRoot: fixture.root, ledger: EchoLedger()) #expect(await git.addGit()) let commitsBefore = try repairHistory(at: fixture.root).count git.stopAutoCommit() diff --git a/KanbanTests/BoardInfoPopoverTests.swift b/KanbanTests/BoardInfoPopoverTests.swift index 83d4fbe..919b29c 100644 --- a/KanbanTests/BoardInfoPopoverTests.swift +++ b/KanbanTests/BoardInfoPopoverTests.swift @@ -2,63 +2,32 @@ import Foundation import Testing @testable import Kanban -/// **The board popover's git-note detection** (12-editions.md ▸ The free tier and `.git`, ruled -/// 2026-07-27): the free tier's whole git story is one line, shown only on a board that carries an -/// inert `.git`. The seam -/// that decides *whether* to show it — `BoardGitNote.hasGitDirectory(at:)` — is a pure, read-only -/// `FileManager` check, pinned here against real bytes on disk. Everything else about the note (its -/// wording, its placement in `BoardGitTabView`) is SwiftUI rendering and deliberately untested. +/// **The popover's git slot, posture by posture** (03-board-ui.md ▸ Board popover ▸ Git tab; +/// 06-history-undo.md ▸ Rules). /// -/// Since the 2026-08-07 tab restructure this same answer decides one thing more: whether the popover -/// carries a Git tab at all on a free board (`BoardInfoTabAvailabilityTests` below). -struct BoardInfoPopoverTests { - - @Test("A board with a .git at its root reports true") - func trueForABoardWithGit() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - - try fixture.item("", Item.board) - try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) - - #expect(BoardGitNote.hasGitDirectory(at: fixture.root)) - } - - @Test("An ordinary board with no .git reports false") - func falseForAnOrdinaryBoard() throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - - try fixture.item("", Item.board) - - #expect(!BoardGitNote.hasGitDirectory(at: fixture.root)) - } -} - -/// **The popover's git slot, posture by posture** (03-board-ui.md ▸ Board popover; 06-history-undo.md -/// ▸ Rules; 12-editions.md ▸ The free tier and `.git`). +/// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the board's +/// **mode** precisely so the matrix is assertable — the views it selects are SwiftUI and stay +/// untested. /// -/// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the tier and the -/// board's mode precisely so the matrix is assertable — the views it selects are SwiftUI and stay -/// untested, exactly as the note's wording and placement do above. +/// **The tier axis came out at 12-editions.md ▸ PIVOT 2026-08-07** (03's Git-tab note records the +/// consequence): git left the paywall, so the two free-tier postures — `.absent` on an ordinary +/// board and the `.proPointer` on one carrying an inert `.git`, both settled 2026-07-27 — described +/// a gate that no longer exists and retired with it. With them went the `.git`-on-disk probe that +/// fed them (`BoardGitNote.hasGitDirectory(at:)`) and the tab-strip membership filter that read +/// `.absent` (`BoardInfoTab.available`, whose suite retired here the same day): detection now runs at +/// every board open on every tier, and the mode is the one input left. @Suite("Board popover ▸ the git section's posture") struct BoardGitSectionTests { - @Test("The free tier: absent on an ordinary board, a Pro pointer on a board carrying an inert .git") - func theFreeTierIsContextual() { - #expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: false) == .absent) - #expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: true) == .proPointer) - } - - @Test("Pro: mode none has no repository to describe, git mode shows the branch") - func proFollowsTheMode() { - #expect(BoardGitSection.resolve(tier: .pro, mode: .none, hasGitDirectory: false) == .noRepository) - #expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch) + @Test("Mode none has no repository to describe, git mode shows the branch") + func thePostureFollowsTheMode() { + #expect(BoardGitSection.resolve(mode: .none) == .noRepository) + #expect(BoardGitSection.resolve(mode: .git) == .branch) } @Test("A repo-nested board explains itself — setup is absent, not disabled") func repoNestedExplainsRatherThanDisables() { - let section = BoardGitSection.resolve(tier: .pro, mode: .repoNested, hasGitDirectory: false) + let section = BoardGitSection.resolve(mode: .repoNested) // The design is insistent here: "not a hidden 'add git' but a short explanation … the option // is absent because it *can't* apply, and the UI should teach that rather than look broken" @@ -74,7 +43,7 @@ struct BoardGitSectionTests { // "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check // is not a found repository, so the two must resolve to different cases even though both are // action-less, prose-only sections. - let section = BoardGitSection.resolve(tier: .pro, mode: .unverifiable, hasGitDirectory: false) + let section = BoardGitSection.resolve(mode: .unverifiable) #expect(section == .unverifiable) #expect(section != .repoNested, "a denial is not a nesting") @@ -88,10 +57,10 @@ struct BoardGitSectionTests { /// The section case is deliberately blind to readability: what changes on such a board is what /// the branch surface inside it says (`BoardGitBranchSurface.resolve`, whose broken presentation /// and own sentence `BoardGitBranchSurfaceTests` pins), not which section the popover shows. The - /// posture matrix stays a function of the tier and the mode alone. + /// posture matrix stays a function of the mode alone. @Test("An unreadable repository is still the branch section — never the no-repository posture") func anUnreadableRepositoryKeepsTheBranchSection() { - let section = BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) + let section = BoardGitSection.resolve(mode: .git) #expect(section == .branch) #expect(section != .noRepository, "the board has a repository; it is unreadable, not absent") @@ -99,84 +68,49 @@ struct BoardGitSectionTests { @Test("Every posture is reachable, and none of them is two postures") func theMatrixIsTotal() { - let resolved = Set( - Tier.allCases.flatMap { tier in - BoardGitMode.allCases.flatMap { mode in - [true, false].map { BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: $0) } - } - } - ) + let resolved = Set(BoardGitMode.allCases.map { BoardGitSection.resolve(mode: $0) }) + // One posture per mode, and every posture spoken for — which is also what makes the tab + // strip's membership rule trivially true since the pivot: no mode resolves to nothing, so + // the Git tab is never dropped (03-board-ui.md ▸ Board popover, the pivot note). #expect(resolved == Set(BoardGitSection.allCases)) + #expect(resolved.count == BoardGitMode.allCases.count, "no two modes share a posture") } } -/// **The tab strip's membership** (03-board-ui.md ▸ Board popover, the tabbed-popover paragraph — -/// the Git session's ruling, 2026-08-07): the Git tab joins the strip only where the git section has -/// something true to say, so a free-tier board with no `.git` shows Info | Theme alone. +/// **The tab strip carries all three tabs, always** (03-board-ui.md ▸ Board popover, the tabbed- +/// popover paragraph as the 2026-08-07 pivot note leaves it): "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." /// -/// `BoardInfoTab.available` is pinned here for the reason `BoardGitSection.resolve` is pinned above: -/// it is the whole decision, pure in its three inputs, and the segmented `Picker` it feeds is SwiftUI -/// and stays untested. What these tests are really about is that membership **is** the posture rather -/// than a second reading of it — the last one walks the entire input matrix and asserts the tab's -/// presence against `resolve` itself, so the strip cannot drift from the surface it labels. -@Suite("Board popover ▸ the tab strip's membership") -struct BoardInfoTabAvailabilityTests { +/// So there is no `available(…)` seam left to pin; what is worth keeping is the strip's *order*, +/// which the popover's `Picker` takes from `allCases` and which the Git session fixed: Info first — +/// it is the board's face and the tab selection resets to it on every open — then Theme, then Git. +@Suite("Board popover ▸ the tab strip") +struct BoardInfoTabStripTests { - @Test("A free board with no .git shows Info and Theme alone — never a standing Git tab") - func theFreeTierDropsTheGitTab() { - // 12-editions.md's "absent, no placeholder" carried up to the tab strip: a Git tab on every - // free board would be the standing ad for Pro that 12 forbids. - #expect(BoardInfoTab.available(tier: .free, mode: .none, hasGitDirectory: false) == [.info, .theme]) + @Test("Info, Theme, Git — in that order, on every board") + func theStripIsTheWholeSet() { + #expect(BoardInfoTab.allCases == [.info, .theme, .git]) } - @Test("A free board carrying an inert .git earns the tab — it has the Pro pointer to say") - func anInertGitEarnsTheTab() { - #expect(BoardInfoTab.available(tier: .free, mode: .none, hasGitDirectory: true) == [.info, .theme, .git]) - } - - @Test("Pro shows the Git tab under every mode — each of the four postures is a tab surface") - func proAlwaysShowsTheTab() { - for mode in BoardGitMode.allCases { - for hasGit in [true, false] { - #expect( - BoardInfoTab.available(tier: .pro, mode: mode, hasGitDirectory: hasGit) - == [.info, .theme, .git], - "Pro, mode \(mode): the tab is the posture's home, and Git is always last" - ) - } - } - } - - @Test("The tab is present exactly where the git section is not absent, across the whole matrix") - func membershipDelegatesToThePosture() { - for tier in Tier.allCases { - for mode in BoardGitMode.allCases { - for hasGit in [true, false] { - let tabs = BoardInfoTab.available(tier: tier, mode: mode, hasGitDirectory: hasGit) - let isAbsent = - BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: hasGit) == .absent - - #expect( - tabs.contains(.git) == !isAbsent, - "\(tier)/\(mode)/git=\(hasGit): membership is the posture, not a second reading of it" - ) - #expect( - Array(tabs.prefix(2)) == [.info, .theme], - "Info and Theme stand on every board, in that order — Info is the board's face" - ) - #expect(tabs.count == Set(tabs).count, "no tab appears twice") - } - } - } + @Test("The raw values are the segmented control's own labels") + func labelsAreTheRawValues() { + // The strip needs no separate label function, which is the only reason this enum is + // `String`-backed at all. + #expect(BoardInfoTab.allCases.map(\.rawValue) == ["Info", "Theme", "Git"]) } } /// **The window-title widget's two strings** (03-board-ui.md ▸ Board popover, the card that widened /// the widget from a chevron to the whole board-name area): `BoardInfoTitlebarSummary` is the pure -/// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its -/// tier/mode inputs with. No disk I/O — the title half of this seam takes a raw `URL`, not a -/// `BoardStore`, so a plain `/tmp/...` path is enough. +/// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its mode +/// input with. No disk I/O — the title half of this seam takes a raw `URL`, not a `BoardStore`, so a +/// plain `/tmp/...` path is enough. +/// +/// **The tier axis came out at 12-editions.md ▸ PIVOT 2026-08-07.** The branch rule read `tier == +/// .pro && mode == .git` until that day; git is tier-independent now, so a git-mode board shows its +/// branch and nothing else does. @Suite("Board popover ▸ the widget's strings") struct BoardInfoTitlebarSummaryTests { @@ -185,17 +119,17 @@ struct BoardInfoTitlebarSummaryTests { @Test("The title is the on-disk title, falling back to the folder name sans extension") func titleFallsBackToTheFolderName() { let named = BoardInfoTitlebarSummary( - snapshotTitle: "Sprint 12", rootURL: root, tier: .free, mode: .none, branch: nil + snapshotTitle: "Sprint 12", rootURL: root, mode: .none, branch: nil ) #expect(named.title == "Sprint 12") let untitled = BoardInfoTitlebarSummary( - snapshotTitle: nil, rootURL: root, tier: .free, mode: .none, branch: nil + snapshotTitle: nil, rootURL: root, mode: .none, branch: nil ) #expect(untitled.title == "My Board") let emptyTitled = BoardInfoTitlebarSummary( - snapshotTitle: "", rootURL: root, tier: .free, mode: .none, branch: nil + snapshotTitle: "", rootURL: root, mode: .none, branch: nil ) #expect( emptyTitled.title == "My Board", @@ -203,43 +137,31 @@ struct BoardInfoTitlebarSummaryTests { ) } - @Test("The branch shows under Pro, in git mode, once it has been read") - func branchShowsForProGitMode() { + @Test("The branch shows in git mode, once it has been read") + func branchShowsInGitMode() { let summary = BoardInfoTitlebarSummary( - snapshotTitle: nil, rootURL: root, tier: .pro, mode: .git, branch: "main" + snapshotTitle: nil, rootURL: root, mode: .git, branch: "main" ) #expect(summary.branch == "main") } - @Test("The free tier never shows a branch, whatever the mode or the git state hands it") - func theFreeTierNeverShowsABranch() { - for mode in BoardGitMode.allCases { + @Test("No other mode shows a branch, whatever the git state hands it") + func onlyGitModeShowsABranch() { + for mode in BoardGitMode.allCases where mode != .git { let summary = BoardInfoTitlebarSummary( - snapshotTitle: nil, rootURL: root, tier: .free, mode: mode, branch: "main" + snapshotTitle: nil, rootURL: root, mode: mode, branch: "main" + ) + #expect( + summary.branch == nil, + "mode \(mode): a board the app manages no repository for must never surface a stray branch value" ) - #expect(summary.branch == nil, "detection never runs under the free tier, so a stray branch value must never surface") } } - @Test("An inert .git under Pro — mode none, repo-nested, or unverifiable — never shows a branch") - func inertGitNeverShowsABranch() { - #expect( - BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .none, branch: "main").branch == nil - ) - #expect( - BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .repoNested, branch: "main").branch - == nil - ) - #expect( - BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .unverifiable, branch: "main").branch - == nil - ) - } - @Test("A git-mode board whose branch has not been read yet shows none, honestly") func unreadBranchShowsNone() { let summary = BoardInfoTitlebarSummary( - snapshotTitle: nil, rootURL: root, tier: .pro, mode: .git, branch: nil + snapshotTitle: nil, rootURL: root, mode: .git, branch: nil ) #expect(summary.branch == nil) } diff --git a/KanbanTests/BoardSettingsSheetTests.swift b/KanbanTests/BoardSettingsSheetTests.swift index 2334aab..2059ee2 100644 --- a/KanbanTests/BoardSettingsSheetTests.swift +++ b/KanbanTests/BoardSettingsSheetTests.swift @@ -9,53 +9,54 @@ import Testing /// They are pinned here for `BoardGitSection.resolve`'s reason one surface over: the *inventory* is /// the decision worth asserting and the SwiftUI that renders it is not. Every case below is a plain /// value — no board on disk, no window, no session. +/// +/// **The tier axis came out at 12-editions.md ▸ PIVOT 2026-08-07**: this inventory resolved `guard +/// tier == .pro` first and the mode second until git left the paywall. What survives is the mode +/// reading, which was always the part carrying the design's reasoning. @Suite("Board settings sheet ▸ the sections") struct BoardSettingsSectionTests { - @Test("Pro, mode none: the sheet is add-git and nothing else") + @Test("Mode none: the sheet is add-git and nothing else") func modeNoneHoldsAddGit() { // "add-git (mode none; opt-in init — 06)" — 03's own first entry for this surface, and the - // control that moved here out of the popover with the split. - #expect(BoardSettingsSection.resolve(tier: .pro, mode: .none) == [.git]) + // control that moved here out of the popover with the split. Every tier's since + // 12-editions.md ▸ PIVOT 2026-08-07, and still an offer rather than an auto-init. + #expect(BoardSettingsSection.resolve(mode: .none) == [.git]) } - @Test("Pro, git mode: branch creation and the commit identity, in that order") + @Test("Git mode: branch creation and the commit identity, in that order") func gitModeHoldsCreationAndIdentity() { // "branch creation (switching stays in the popover…)" and "commit identity name/email (06 — // the visibility-scoped 2 s config re-read rides with the fields)". - #expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity]) + #expect(BoardSettingsSection.resolve(mode: .git) == [.branch, .commitIdentity]) } @Test("Every section is reachable from some posture, and no posture invents one") func theInventoryIsTotal() { - let offered = Set( - Tier.allCases.flatMap { tier in - BoardGitMode.allCases.flatMap { BoardSettingsSection.resolve(tier: tier, mode: $0) } - } - ) + let offered = Set(BoardGitMode.allCases.flatMap { BoardSettingsSection.resolve(mode: $0) }) #expect(offered == Set(BoardSettingsSection.allCases)) } - @Test("Pro, unverifiable: the sheet has nothing to show — structurally like repo-nested") + @Test("Unverifiable: the sheet has nothing to show — structurally like repo-nested") func unverifiableHoldsNothing() { // "Denial is not absence" (06 ▸ Rules ▸ Detection, ruled 2026-07-31): a denied ancestor check // can never be told apart from a repository actually being there, so add-git stays as // unreachable here as it is on a genuinely nested board. - #expect(BoardSettingsSection.resolve(tier: .pro, mode: .unverifiable) == []) + #expect(BoardSettingsSection.resolve(mode: .unverifiable) == []) } - @Test("Pro, git mode with an unreadable repository: nothing setup-shaped applies either") + @Test("Git mode with an unreadable repository: nothing setup-shaped applies either") func anUnreadableRepositoryHoldsNothing() { // **The corrupt-`.git` loud failure** (06 ▸ Rules, ruled 2026-07-31): both sections here are // *writes* to a repository — a branch created in it, an identity written into its config — // and there is no repository the app can open to write either into. Repo-nested's emptiness, // reached one step further along. - #expect(BoardSettingsSection.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true) == []) + #expect(BoardSettingsSection.resolve(mode: .git, isRepositoryUnreadable: true) == []) // …and the mode is still `git` throughout: this is emptiness *within* git mode, never the // fall to mode none that would let add-git be offered against an existing `.git`. - #expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity]) + #expect(BoardSettingsSection.resolve(mode: .git) == [.branch, .commitIdentity]) } @Test("The sections carry the headers VoiceOver navigates by") @@ -69,41 +70,34 @@ struct BoardSettingsSectionTests { } } -/// **Where the sheet can be opened from, tier by mode** — the answer both doors validate on: Board ▸ +/// **Where the sheet can be opened from, mode by mode** — the answer both doors validate on: Board ▸ /// Board Settings…'s `disabled` state, and whether the popover's git section renders its Board /// Settings… row at all. +/// +/// **The Pro clause is gone** (12-editions.md ▸ PIVOT 2026-08-07): every board can reach the setup +/// its own mode leaves it, on any tier. @Suite("Board settings sheet ▸ availability") struct BoardSettingsAvailabilityTests { - @Test("The whole matrix: Pro on a none-or-git board, and nowhere else") + @Test("The whole matrix: a none-or-git board, and nowhere else") func theMatrix() { - // Pro, and a board whose mode leaves something to set up. - #expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .none)) - #expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .git)) + // A board whose mode leaves something to set up. + #expect(BoardSettingsAvailability.resolve(mode: .none)) + #expect(BoardSettingsAvailability.resolve(mode: .git)) - // **Pro, repo-nested**: "nothing setup-shaped can apply" — no add-git (06's prose, not a + // **Repo-nested**: "nothing setup-shaped can apply" — no add-git (06's prose, not a // disabled button), no branch of ours to create, no repo-local config of ours to write. The // popover's explanation stands and no door opens. - #expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .repoNested)) + #expect(!BoardSettingsAvailability.resolve(mode: .repoNested)) - // **Pro, unverifiable**: the same unreachability, for the denial-not-absence reason — a - // denied ancestor check is never distinguishable from a repository actually being there. - #expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .unverifiable)) + // **Unverifiable**: the same unreachability, for the denial-not-absence reason — a denied + // ancestor check is never distinguishable from a repository actually being there. + #expect(!BoardSettingsAvailability.resolve(mode: .unverifiable)) - // **Pro, git mode over a repository that will not open**: no door either, so neither the + // **Git mode over a repository that will not open**: no door either, so neither the // popover's Board Settings… row nor the menu command offers a surface with nothing on it // (06 ▸ Rules, the corrupt-`.git` loud failure). - #expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true)) - - // **The free tier**: no setup exists there at all (12-editions.md ▸ The free tier and - // `.git`), whatever mode a stray value claims — detection never runs off Pro, so the mode is - // swept for completeness rather than because it can vary. - for mode in BoardGitMode.allCases { - #expect( - !BoardSettingsAvailability.resolve(tier: .free, mode: mode), - "the free tier has no board settings sheet in any mode" - ) - } + #expect(!BoardSettingsAvailability.resolve(mode: .git, isRepositoryUnreadable: true)) } @Test("Reachable means exactly 'has something to show'") @@ -111,13 +105,11 @@ struct BoardSettingsAvailabilityTests { // The derivation, not a coincidence: a surface whose whole job is hosting setup controls has // no honest empty state, so the two answers are one answer. pro-m2's sections join the // inventory and this identity keeps holding. - for tier in Tier.allCases { - for mode in BoardGitMode.allCases { - #expect( - BoardSettingsAvailability.resolve(tier: tier, mode: mode) - == !BoardSettingsSection.resolve(tier: tier, mode: mode).isEmpty - ) - } + for mode in BoardGitMode.allCases { + #expect( + BoardSettingsAvailability.resolve(mode: mode) + == !BoardSettingsSection.resolve(mode: mode).isEmpty + ) } } } @@ -133,10 +125,11 @@ struct BoardSettingsPresentationTests { let presentation = BoardSettingsPresentation() #expect(presentation.isPresented == false) - #expect(presentation.tier == .free) #expect(presentation.git == nil) - // The free tier's posture is the safe default for a window whose session has not been adopted - // yet: an unreachable sheet, rather than a sheet with no sections in it. + // No session adopted yet, so no sheet: every control this surface hosts writes *through* the + // git state, and a sheet of bare headers is the empty state the design forbids. (It was the + // free tier's default that kept this shut before 12-editions.md ▸ PIVOT 2026-08-07; the + // absence of a session is what keeps it shut now.) #expect(presentation.isReachable == false) #expect(presentation.sections.isEmpty) } @@ -146,7 +139,7 @@ struct BoardSettingsPresentationTests { let presentation = BoardSettingsPresentation() presentation.present() - #expect(presentation.isPresented == false, "the free tier has no sheet to open") + #expect(presentation.isPresented == false, "an unadopted window has no sheet to open") } @Test("Two windows hold their own flags") @@ -160,19 +153,20 @@ struct BoardSettingsPresentationTests { #expect(second.isPresented == false) } - @Test("Adopting a Pro git session makes the sheet reachable, and dismissal is idempotent") + @Test("Adopting a session makes the sheet reachable, and dismissal is idempotent") func adoptingASession() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) // Mode `none` — the add-git posture, which is the sheet's whole job on a board with no - // repository yet. - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + // repository yet. Composed with no tier in sight: git is tier-independent since + // 12-editions.md ▸ PIVOT 2026-08-07, so this is every session's git state, not Pro's. + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .none) let presentation = BoardSettingsPresentation() - presentation.adopt(tier: .pro, git: git) + presentation.adopt(git: git) #expect(presentation.isReachable) #expect(presentation.sections == [.git]) @@ -185,23 +179,31 @@ struct BoardSettingsPresentationTests { #expect(presentation.isPresented == false) } - @Test("The free tier's session leaves both doors shut, .git on the folder or not") - func aFreeTierSessionIsUnreachable() throws { + @Test("A board carrying a `.git` is a git board — the inert posture is retired") + func anInertGitIsNoLongerInert() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) - // `compose` is the tier gate: the free tier gets no git state at all, so there is nothing for - // a settings sheet to be about even on a board carrying an inert `.git` — the popover's - // one-line Pro pointer is that board's whole story (12-editions.md). - #expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil) + // The retired posture, asserted where it used to bite: a `.git` at a board root was **inert** + // off Pro — never read, never written, the popover's one-line Pro pointer that board's whole + // story. Detection runs at every board open on every tier now (12-editions.md ▸ PIVOT + // 2026-08-07), so this composes in git mode with no tier asked for. + let git = HistoryStore.compose(boardRoot: fixture.root) + #expect(git.mode == .git) + + // And what these three bytes actually are is a repository libgit2 will not open, so the board + // takes **06's corrupt-`.git` posture, not the retired inert one** (06 ▸ Rules, ruled + // 2026-07-31): git mode throughout — never a fall to mode none that would offer add-git + // against an existing `.git` — with both doors shut because every section here is a write + // into something the app cannot open. + #expect(git.isRepositoryUnreadable) let presentation = BoardSettingsPresentation() - presentation.adopt(tier: .free, git: nil) + presentation.adopt(git: git) #expect(presentation.isReachable == false) - presentation.present() - #expect(presentation.isPresented == false) + #expect(presentation.sections.isEmpty) } } diff --git a/KanbanTests/BranchSwitchTests.swift b/KanbanTests/BranchSwitchTests.swift index 97a23cd..9730101 100644 --- a/KanbanTests/BranchSwitchTests.swift +++ b/KanbanTests/BranchSwitchTests.swift @@ -37,7 +37,7 @@ private func makeBoard() throws -> WriterFixture { @MainActor private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore) { let fixture = try makeBoard() - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: EchoLedger())) + let git = HistoryStore.compose(boardRoot: fixture.root, ledger: EchoLedger()) #expect(await git.addGit()) await git.refreshBranch() return (fixture, git) @@ -1034,7 +1034,7 @@ struct BoardGitBranchSurfaceTests { /// is that git mode now carries controls rather than a read-only line. @Test("Git mode still resolves to the branch section — now the one with controls in it") func gitModeResolvesToTheBranchSection() { - #expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch) + #expect(BoardGitSection.resolve(mode: .git) == .branch) } } diff --git a/KanbanTests/CardSessionCommitTests.swift b/KanbanTests/CardSessionCommitTests.swift index 258ed70..ea8a9bf 100644 --- a/KanbanTests/CardSessionCommitTests.swift +++ b/KanbanTests/CardSessionCommitTests.swift @@ -137,7 +137,7 @@ private func makeWindow() async throws -> Window { let store = try BoardStore(rootURL: fixture.root) await store.awaitQuiescence() - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: store.echoes)) + let git = HistoryStore.compose(boardRoot: fixture.root, ledger: store.echoes) #expect(await git.addGit()) let committer = try #require(git.committer) @@ -494,7 +494,7 @@ struct CardSessionStagingWiringTests { try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login")) - let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let seed = HistoryStore.compose(boardRoot: fixture.root) #expect(await seed.addGit()) return (fixture, try openBoard(model, at: fixture.root)) } diff --git a/KanbanTests/GitUndoTests.swift b/KanbanTests/GitUndoTests.swift index 50a73cb..02b212f 100644 --- a/KanbanTests/GitUndoTests.swift +++ b/KanbanTests/GitUndoTests.swift @@ -39,7 +39,7 @@ private func makeBoard() throws -> WriterFixture { private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) { let fixture = try makeBoard() let ledger = EchoLedger() - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: ledger)) + let git = HistoryStore.compose(boardRoot: fixture.root, ledger: ledger) #expect(await git.addGit()) return (fixture, git, ledger) } @@ -998,32 +998,36 @@ struct GitUndoSessionTests { // MARK: - The provider binding -/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12-editions.md -/// ▸ The provider seam; 13-native-undo.md's header; 06-history-undo.md ▸ Rules), stated as the -/// matrix it is: **boards without app-managed git — repo-nested included — bind the native stack in -/// *every* tier** ("an upgrade never removes undo"), and a Pro git board binds the git provider. -/// There is no third answer any more. +/// **The provider follows the board — and since PIVOT 2026-08-07, the board alone** (re-ruled +/// 2026-07-31 — 12-editions.md ▸ The provider seam; 13-native-undo.md's header; 06-history-undo.md +/// ▸ Rules; then 12 ▸ PIVOT 2026-08-07, git leaves the paywall), stated as the matrix it is: a +/// **git-mode board binds the git provider** and **every other board binds the native stack** — +/// repo-nested and unverifiable included — in *every* tier. There is no third answer and no second +/// axis. /// -/// ### The repo-nested row stopped being an exception +/// ### What the pivot changed here, and what it did not /// -/// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all -/// and cannot tell a repo-nested board from a plain one — which is not an omission but 12 ▸ The free -/// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a -/// 1.x board, **a repo-nested board**) works normally — files read and write as on any board, -/// **native undo runs**". Pro used to answer differently on the same board, which made subscribing -/// *remove* ⌘Z from it; the re-ruling of 2026-07-31 retired that case outright — "leave strictly -/// alone concerns *git*, and this stack never touches git — memory-only, journal-free, -/// session-scoped" (13's header) — so the two tiers now agree on every board there is, and the tests -/// below pin both halves of that agreement. +/// The 2026-07-31 re-ruling had already taken the tier out of every row but one: it still decided +/// whether git was on the table at all, because `HistoryStore.compose` returned `nil` off Pro, so a +/// free session never detected a mode and could not tell a repo-nested board from a plain one. The +/// pivot removes that last row — "every tier composes the git stack on git-mode boards exactly as +/// Pro did" — so a free-tier session on a git board now binds the *git* provider, which is the +/// inversion `freeTierBindsGitOnAGitBoard` below pins. +/// +/// What survives untouched is the gitless side: a board nobody added git to detects `none` and binds +/// the native stack, for good, because git stays **opt-in per board** (06 ▸ Rules; 13's header read +/// through the pivot). And the repo-nested row keeps the 2026-07-31 reasoning that retired it as an +/// exception — "leave strictly alone concerns *git*, and this stack never touches git — memory-only, +/// journal-free, session-scoped" (13's header). @MainActor @Suite("Git undo ▸ which board gets a provider") struct GitUndoBindingTests { - @Test("Free tier binds the native stack everywhere, git or not") - func freeTierIsNativeEverywhere() async throws { + @Test("Free tier on a git board binds the git provider — git left the paywall") + func freeTierBindsGitOnAGitBoard() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let seed = HistoryStore.compose(boardRoot: fixture.root) #expect(await seed.addGit()) let (model, tearDown) = try makeModel() @@ -1033,16 +1037,20 @@ struct GitUndoBindingTests { let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) - #expect(session.git == nil, "the free tier composes no git state at all") - #expect(session.history is NativeHistoryProvider) - #expect(session.undoManager.canUndo == false, "empty, not absent") + // **PIVOT 2026-08-07** (12-editions.md), and the exact inversion of what this test used to + // pin: `session.git == nil` and the native stack, because the free tier composed no git state + // at all and this board's `.git` was inert. It is live now, in every tier. + #expect(session.git != nil, "every session composes a git state") + #expect(session.gitMode == .git) + #expect(session.history is GitHistoryProvider) + #expect(session.tier == .free, "recorded, and consulted by nothing on this path") } @Test("Pro on a git board binds the git provider") func proOnAGitBoardBindsGit() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let seed = HistoryStore.compose(boardRoot: fixture.root) #expect(await seed.addGit()) let (model, tearDown) = try makeModel() @@ -1055,8 +1063,8 @@ struct GitUndoBindingTests { #expect(session.history is GitHistoryProvider) } - @Test("Pro on a mode-none board binds the native stack — an upgrade never removes undo") - func proOnAPlainBoardBindsTheNativeStack() throws { + @Test("A mode-none board binds the native stack — opt-in is what keeps it there") + func aPlainBoardBindsTheNativeStack() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() @@ -1066,14 +1074,16 @@ struct GitUndoBindingTests { let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) - // The board is Pro's — it composed a git state and detected a mode — and the mode is what - // chose the substrate: "gitless boards bind the native undo stack in every tier" (12). - #expect(session.git != nil, "Pro composes a git state even where there is no repository") + // The board composed a git state and detected a mode, and the mode is what chose the + // substrate: "gitless boards bind the native undo stack in every tier" (12). Since PIVOT + // 2026-08-07 the *only* thing keeping a board on this row is that nobody added git to it — + // opt-in per board is exactly what the pivot left standing (06 ▸ Rules; 13's header). + #expect(session.git != nil, "a git state is composed even where there is no repository") #expect(session.gitMode == .none) #expect(session.history is NativeHistoryProvider) - // And it is a *working* stack, not a placeholder: the same command surface a free-tier - // session gets, which is what "a user subscribing relearns nothing" means here. + // And it is a *working* stack, not a placeholder: the same command surface every other + // gitless board gets, which is what "a user relearns nothing" means here. let log = StepLog() #expect(session.undoManager.canUndo == false, "empty, not absent") session.history?.register(log.step("Move Card")) @@ -1083,8 +1093,8 @@ struct GitUndoBindingTests { #expect(log.crossings == ["undo Move Card"]) } - @Test("Pro on a repo-nested board binds the native stack — the no-undo case is retired") - func proOnARepoNestedBoardBindsTheNativeStack() throws { + @Test("A repo-nested board binds the native stack — the no-undo case is retired") + func aRepoNestedBoardBindsTheNativeStack() throws { let outer = try WriterFixture() defer { outer.tearDown() } // A repository at the *parent*, with the board inside it — the nested posture. @@ -1101,15 +1111,15 @@ struct GitUndoBindingTests { let ref = try openBoard(model, at: boardRoot) let session = try #require(model.session(for: ref)) - // The mode is detected — Pro walks the ancestors — and it no longer decides ⌘Z: "Pro binds it - // on mode-none **and repo-nested** boards alike … what repo-nested denies is app-managed - // history, never ⌘Z" (13's header, re-ruled 2026-07-31). + // The mode is detected — the ancestor walk runs — and it no longer decides ⌘Z: "mode-none + // **and repo-nested** boards alike bind it … what repo-nested denies is app-managed history, + // never ⌘Z" (13's header, re-ruled 2026-07-31). #expect(session.gitMode == .repoNested) #expect(session.history is NativeHistoryProvider) #expect(session.undoManager.canUndo == false, "empty, not absent") - // A working stack, exactly as the free tier's on this same board — which is the whole point - // of the re-ruling: the two tiers answer alike here now. + // A working stack, exactly as on the same board under any other tier — see + // `freeTierOnARepoNestedBoardIsNativeToo`, which now detects the same mode this one does. let log = StepLog() session.history?.register(log.step("Move Card")) #expect(session.undoManager.canUndo) @@ -1142,12 +1152,12 @@ struct GitUndoBindingTests { defer { tearDown() } let git = HistoryStore(boardRoot: fixture.root, mode: .unverifiable, ledger: EchoLedger()) - let provider = model.makeHistoryProvider(store, .pro, git) + let provider = model.makeHistoryProvider(store, git) #expect(provider is NativeHistoryProvider) } - @Test("The free tier's repo-nested board still binds the native stack — it never detects one") + @Test("The free tier's repo-nested board binds the native stack too — now that it detects one") func freeTierOnARepoNestedBoardIsNativeToo() throws { let outer = try WriterFixture() defer { outer.tearDown() } @@ -1164,15 +1174,17 @@ struct GitUndoBindingTests { let ref = try openBoard(model, at: boardRoot) let session = try #require(model.session(for: ref)) - // 12 ▸ The free tier and `.git` names this board by hand: "opening a board that has one … - // a repo-nested board … works normally … native undo runs". The tier composes no git state - // at all, so there is nothing here that *could* tell this board from a plain one — the inert - // posture made structural rather than remembered. - #expect(session.git == nil) - #expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode") + // The answer is unchanged and the *reason* is new. Before PIVOT 2026-08-07 the free tier + // composed no git state, so nothing here could tell this board from a plain one and native + // undo followed by default — "opening a board that has one … a repo-nested board … works + // normally … native undo runs" (12 ▸ The free tier and `.git`, now retired). Detection runs + // in every tier now, sees the enclosing repository, and binds native anyway, because what + // repo-nested denies is app-managed history and never ⌘Z (13's header, re-ruled 2026-07-31). + #expect(session.git != nil, "detection runs here now") + #expect(session.gitMode == .repoNested, "and it sees the enclosing repository") #expect(session.history is NativeHistoryProvider) - // Pro on this same board now answers identically (above) — the two tiers agree, which is what - // the re-ruling of 2026-07-31 bought: an upgrade never removes undo from *any* board. + // The same board under `.pro` answers identically (above) — one matrix, no tier column. + #expect(session.git?.committer == nil, "and the enclosing repository is still left alone") } @Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded") @@ -1265,7 +1277,7 @@ struct GitOperationFailureBannerTests { func aFailedRestorePostsAFailureRow() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let seed = HistoryStore.compose(boardRoot: fixture.root) #expect(await seed.addGit()) let (model, tearDown) = try makeModel() @@ -1302,7 +1314,7 @@ struct GitOperationFailureBannerTests { func theSwitcherReportsFailureAndRecoveryDifferently() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let seed = HistoryStore.compose(boardRoot: fixture.root) #expect(await seed.addGit()) let (model, tearDown) = try makeModel() @@ -1336,7 +1348,7 @@ struct GitUndoRoutingTests { func textEditingWins() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let seed = HistoryStore.compose(boardRoot: fixture.root) #expect(await seed.addGit()) let (model, tearDown) = try makeModel() diff --git a/KanbanTests/HistoryProviderTests.swift b/KanbanTests/HistoryProviderTests.swift index 1e3c271..767cd49 100644 --- a/KanbanTests/HistoryProviderTests.swift +++ b/KanbanTests/HistoryProviderTests.swift @@ -587,7 +587,7 @@ struct BoardSessionHistoryTests { defer { tearDown() } let bound = FakeHistoryProvider() - model.makeHistoryProvider = { _, _, _ in bound } + model.makeHistoryProvider = { _, _ in bound } let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) @@ -597,28 +597,30 @@ struct BoardSessionHistoryTests { #expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound") } - @Test("The tier reaches the composition root, and the session records what it composed under") - func theTierIsAComposedFact() throws { + @Test("The session records the tier it composed under — and the provider root never sees it") + func theTierIsARecordedFact() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() defer { tearDown() } - // 12-editions.md ▸ The entitlement: the tier is read at composition, once, and handed to the - // root that binds the provider. What this pins is that the *argument arrives* — the matrix it - // feeds is `GitUndoBindingTests`', since after the re-ruling of 2026-07-31 the tier decides - // only whether a git state is composed at all, and the mode decides the substrate. - var seen: [Tier] = [] + // 12-editions.md ▸ The entitlement: the tier is read at composition, once, and recorded. + // **PIVOT 2026-08-07**: it is no longer *handed* anywhere. This test used to pin the argument + // arriving at `makeHistoryProvider` (`{ _, tier, _ in }`); the parameter is gone, so what is + // pinned now is the pair of facts that replaced it — the seam takes a store and a git state + // and nothing else, and the session still carries the tier for the base/Pro split yet to be + // ruled. The substrate matrix itself is `GitUndoBindingTests`'. + var seen = 0 model.currentTier = { .pro } - model.makeHistoryProvider = { _, tier, _ in - seen.append(tier) + model.makeHistoryProvider = { _, _ in + seen += 1 return NativeHistoryProvider() } let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) - #expect(seen == [.pro]) + #expect(seen == 1, "called once per board, with no tier to consult") #expect(session.tier == .pro, "the session carries the fact it composed under") } @@ -635,7 +637,10 @@ struct BoardSessionHistoryTests { // The subscription lapses mid-session — the one thing 12 ▸ The entitlement says must not // disturb a board that is already on screen: "an open board finishes with the provider it - // composed; the next open composes the native stack over inert `.git`". + // composed". (Its second clause — "the next open composes the native stack over inert + // `.git`" — went with PIVOT 2026-08-07: the next open composes the git stack too. What is + // pinned here is the recorded fact's immutability, which is what makes the first clause + // true whatever a future split gates.) model.currentTier = { .free } #expect(try #require(model.session(for: ref)).tier == .pro) diff --git a/KanbanTests/HistoryStoreTests.swift b/KanbanTests/HistoryStoreTests.swift index 157490c..5d5952d 100644 --- a/KanbanTests/HistoryStoreTests.swift +++ b/KanbanTests/HistoryStoreTests.swift @@ -4,8 +4,9 @@ import Testing @testable import Kanban /// **The board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components -/// ▸ HistoryStore) — composed under the tier, detected at open, and changed afterwards by exactly -/// one thing. +/// ▸ HistoryStore) — composed for every session, detected at open, and changed afterwards by +/// exactly one thing. It was "composed under the tier" until PIVOT 2026-08-07 (12-editions.md — git +/// left the paywall); the tier axis is gone from composition and from everything below it. /// /// Every repository here is a **real** one, made by the app's own add-git through the bundled /// libgit2: the card's first criterion is that adding git "initializes a repo at the board root with @@ -40,8 +41,8 @@ private func commitEverything(at boardRoot: URL, message: String) throws { _ = try repository.commit(message: message) } -/// Bytes and mtimes of everything under a subtree — `InertGitTests`' instrument, in the shape this -/// file needs it: what proves that *reading* a board's mode touched nothing. +/// Bytes and mtimes of everything under a subtree — `UntouchedGitTests`' instrument, in the shape +/// this file needs it: what proves that *reading* a board's mode touched nothing. private struct SubtreeEntry: Equatable { let path: String let data: Data? @@ -102,33 +103,37 @@ private func plantSHA256Repository(in fixture: WriterFixture) throws { // MARK: - Composition @MainActor -@Suite("HistoryStore ▸ composition and the tier gate") +@Suite("HistoryStore ▸ composition and open-time detection") struct HistoryStoreCompositionTests { - @Test("The free tier composes no git state at all, on any board") - func theFreeTierComposesNothing() throws { + @Test("Composition takes no tier: a board carrying a repository opens in git mode, full stop") + func compositionIsUnconditional() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try plantGitDirectory(in: fixture) - // Not "mode none on a git board" — *nothing*. With no object there is no path by which a - // free-tier session could read history, commit, or touch `.git` (12-editions.md ▸ The free - // tier and `.git`, whose byte-level half is `InertGitTests`). - #expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil) + // **PIVOT 2026-08-07** (12-editions.md — git left the paywall). This was the tier gate's own + // test, and it read the other way: `compose(boardRoot:tier: .free)` answered `nil`, so a free + // session had no git state to consult and never stat'ed a `.git` (the inert posture, made + // structural). The gate is gone — the parameter with it — and detection now runs on this + // board for every session there is, which is what the assertion below says by having no tier + // to name. + let git = HistoryStore.compose(boardRoot: fixture.root) + #expect(git.mode == .git, "the `.git` at the root is live, not inert") } - @Test("Pro on a plain board is mode none — and opening one never creates a repository") - func proOnAPlainBoardIsModeNone() throws { + @Test("A plain board is mode none — and opening one never creates a repository") + func aPlainBoardIsModeNone() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .none) // "No silent auto-init, ever" (06 ▸ Rules) — the deliberate pivot from the pathfinder, which // initialized a repository under every board it opened. Composing twice is the whole test: // two opens, no repository. - _ = HistoryStore.compose(boardRoot: fixture.root, tier: .pro) + _ = HistoryStore.compose(boardRoot: fixture.root) #expect(!fixture.exists(".git"), "opening a mode-none board is not an opt-in") } @@ -139,14 +144,14 @@ struct HistoryStoreCompositionTests { // The board a second machine meets: someone ran `git init`/`git clone` (here, the app's own // add-git in a previous session), and the repository is simply there. - let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let first = HistoryStore.compose(boardRoot: fixture.root) #expect(await first.addGit()) let afterInit = try #require(GitRepository.headCommit(at: fixture.root)) let before = try snapshotGitDirectory(fixture.root) // The next open. Adoption is not init: no dialog, no confirmation, no second step — the mode // is simply what the filesystem says, and it says git. - let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let second = HistoryStore.compose(boardRoot: fixture.root) #expect(second.mode == .git) // And nothing happened to the repository on the way in: same HEAD, same bytes, same mtimes. @@ -165,7 +170,7 @@ struct HistoryStoreCompositionTests { try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) - let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) + let git = HistoryStore.compose(boardRoot: boardRoot) #expect(git.mode == .repoNested) } @@ -174,7 +179,7 @@ struct HistoryStoreCompositionTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .none) // What a `git init` in a terminal under an open board does — which is nothing, until the @@ -183,7 +188,7 @@ struct HistoryStoreCompositionTests { try plantGitDirectory(in: fixture) #expect(git.mode == .none, "the open session keeps the mode it composed with") - let nextOpen = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let nextOpen = HistoryStore.compose(boardRoot: fixture.root) #expect(nextOpen.mode == .git, "and the next open reflects what it finds") } } @@ -207,11 +212,11 @@ struct HistoryStoreUnreadableRepositoryTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let first = HistoryStore.compose(boardRoot: fixture.root) #expect(await first.addGit()) // The next open, which is where the probe actually runs. - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .git) #expect(!git.isRepositoryUnreadable) #expect(git.committer?.pause == nil) @@ -226,7 +231,7 @@ struct HistoryStoreUnreadableRepositoryTests { // filesystem one question, and not a repository at all to libgit2. try plantGitDirectory(in: fixture) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) // **Never a fall to mode none** — "detection is presence-shaped … a corrupt or unopenable // repo never falls to mode none", which is what keeps add-git from ever being offered @@ -251,7 +256,7 @@ struct HistoryStoreUnreadableRepositoryTests { defer { fixture.tearDown() } try plantDanglingGitPointer(in: fixture) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .git, "a `.git` file is a repository to git — presence is presence") #expect(git.isRepositoryUnreadable) } @@ -262,7 +267,7 @@ struct HistoryStoreUnreadableRepositoryTests { defer { fixture.tearDown() } try plantSHA256Repository(in: fixture) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .git) // 06 ▸ Repository hygiene: "an adopted SHA-256 repo the engine cannot open takes the // corrupt-repo loud-failure path — never a silent fall to mode-none". @@ -276,7 +281,7 @@ struct HistoryStoreUnreadableRepositoryTests { try plantGitDirectory(in: fixture) let before = try snapshotGitDirectory(fixture.root) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.isRepositoryUnreadable) // "Lanework leaves the repository untouched" — the same bytes and the same mtimes, on the @@ -290,7 +295,7 @@ struct HistoryStoreUnreadableRepositoryTests { defer { fixture.tearDown() } try plantGitDirectory(in: fixture) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) await git.writeIdentity(name: "Ada", email: "ada@example.com") #expect(!fixture.exists(".git/config"), "no config was written into a repository nothing can open") @@ -334,7 +339,7 @@ struct HistoryStoreAddGitTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) #expect(fixture.exists(".git"), "a repository at the board root — bundled libgit2, no git install") @@ -358,7 +363,7 @@ struct HistoryStoreAddGitTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) // The one file the app ever writes into a board because of git, and the one moment it writes @@ -374,7 +379,7 @@ struct HistoryStoreAddGitTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) let derived = GitIdentity.derivedDefault() @@ -388,7 +393,7 @@ struct HistoryStoreAddGitTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) // Deterministic rather than inherited: libgit2's initial branch comes from an @@ -409,7 +414,7 @@ struct HistoryStoreAddGitTests { // (06 ▸ Rules ▸ Abnormal repo states: "an unborn HEAD is normal git mode"). _ = try Repository.create(at: fixture.root) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .git) #expect(GitRepository.branchName(at: fixture.root) != nil) #expect(GitRepository.headCommit(at: fixture.root) == nil, "no commits yet, and that is fine") @@ -420,11 +425,11 @@ struct HistoryStoreAddGitTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let first = HistoryStore.compose(boardRoot: fixture.root) #expect(await first.addGit()) let head = try #require(GitRepository.headCommit(at: fixture.root)) - let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let second = HistoryStore.compose(boardRoot: fixture.root) #expect(await second.addGit() == false, "there is nothing to add") #expect(GitRepository.headCommit(at: fixture.root)?.oid == head.oid, "and nothing was re-initialized") } @@ -439,7 +444,7 @@ struct HistoryStoreAddGitTests { try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) - let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) + let git = HistoryStore.compose(boardRoot: boardRoot) #expect(await git.addGit() == false) #expect(git.mode == .repoNested) #expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)) @@ -475,7 +480,7 @@ struct HistoryStoreAddGitTests { // Composed while the enclosing folder is still a plain one: the store's mode is `none`, and // that is the reading that goes stale. - let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) + let git = HistoryStore.compose(boardRoot: boardRoot) #expect(git.mode == .none) // A terminal `git init` one level up, after the detection the store is holding. @@ -530,7 +535,7 @@ struct HistoryStoreAddGitTests { // Mode is read once, at composition — so a store composed before a `.git` appeared still says // `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the // question is where the answer lands. - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) try plantGitDirectory(in: fixture) var banners: [String] = [] git.reportFailure = { banners.append($0.message) } @@ -563,7 +568,7 @@ struct GitPathHistoryTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) // A second card, arriving in a later commit — the whole point of the rung. @@ -587,7 +592,7 @@ struct GitPathHistoryTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let plain = HistoryStore.compose(boardRoot: fixture.root) #expect(plain.identityHistoryRanker == nil, "mode none injects nothing") // And the free tier has no `HistoryStore` to ask in the first place — pinned in the @@ -600,7 +605,7 @@ struct GitPathHistoryTests { defer { fixture.tearDown() } try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) // The same identity, arriving later in a second lane — the copy the duplicate rule is about. @@ -648,32 +653,37 @@ private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef @Suite("Board sessions ▸ the git state they compose") struct BoardSessionGitTests { - @Test("A free-tier session carries no git state, even on a board that has a repository") - func freeSessionsCarryNoGitState() throws { + @Test("A free-tier session composes a git state too, and detects the repository it finds") + func freeSessionsComposeAGitStateAsWell() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try plantGitDirectory(in: fixture) let (model, tearDown) = try makeModel() defer { tearDown() } + // **PIVOT 2026-08-07** (12-editions.md). The inverse of what this test used to pin: the free + // tier carried no git state at all, reported mode `none` on this very board, and injected + // nothing into the loader. Git left the paywall, so the session composes exactly what a Pro + // session composes — same call, no tier in it. model.currentTier = { .free } let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) - #expect(session.git == nil) - #expect(session.gitMode == .none, "the free tier ships exactly one mode") - #expect(session.store.makeIdentityHistoryRanker == nil, "and injects nothing into the loader") + #expect(session.git != nil) + #expect(session.gitMode == .git, "the mode is the disk's answer, not the tier's") + #expect(session.store.makeIdentityHistoryRanker != nil, "and the loader's rung is wired") + #expect(session.tier == .free, "the tier is still recorded — it just decides nothing here") } - @Test("A Pro session on a git board composes git mode and wires the loader's ranker") - func proSessionsCarryTheDetectedMode() async throws { + @Test("A session on a git board composes git mode and wires the loader's ranker") + func sessionsCarryTheDetectedMode() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() defer { tearDown() } // A real repository, so the ranker has something to read. - let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let seed = HistoryStore.compose(boardRoot: fixture.root) #expect(await seed.addGit()) model.currentTier = { .pro } @@ -685,13 +695,13 @@ struct BoardSessionGitTests { let ranker = try #require(provider()) #expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil) - // The provider binding arrived with the undo/redo card: a Pro session on a git board binds - // the git substrate over exactly this mode (12-editions.md ▸ The provider seam). + // The provider binding arrived with the undo/redo card: a session on a git board binds the + // git substrate over exactly this mode (12-editions.md ▸ The provider seam). #expect(session.history is GitHistoryProvider) } - @Test("A Pro session on a plain board is mode none and injects nothing") - func proSessionsOnPlainBoardsInjectNothing() throws { + @Test("A session on a plain board is mode none and injects nothing") + func sessionsOnPlainBoardsInjectNothing() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() diff --git a/KanbanTests/InertGitTests.swift b/KanbanTests/InertGitTests.swift index 4b899a4..e6fa70a 100644 --- a/KanbanTests/InertGitTests.swift +++ b/KanbanTests/InertGitTests.swift @@ -2,24 +2,37 @@ import Foundation import Testing @testable import Kanban -/// **The inert-`.git` posture, stated against real bytes on disk** (12-editions.md ▸ The free tier -/// and `.git`): "any `.git` is inert — opening a board that has one works normally, but the app -/// never reads history, never commits, never touches `.git` in any way." +/// **The file layer never touches `.git`, stated against real bytes on disk** — every write and every +/// load the app makes leaves the whole `.git` subtree byte- and mtime-identical. /// -/// It is unconditional app behaviour, not a build-time posture: the app that ships is the only app -/// there is (12 ▸ The target, re-ruled 2026-07-30), and it runs this way for everyone without a -/// subscription and for everyone whose subscription has lapsed — "unsubscribed and lapsed are one -/// state". So these tests assert what `Kanban` does, full stop; nothing here is conditioned on a -/// tier, and the git provider that will one day touch `.git` composes over its own boards. +/// ### What this file used to be, and what it is now /// -/// Two halves of that posture are already pinned elsewhere and are referenced, not repeated: -/// `FolderWatcherTests` ▸ ".git filtering" proves the watcher ignores churn under a `.git` at any -/// depth, and `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a -/// `.git` at the board root loads as an ordinary stray with no warning. Both are *input* claims: -/// what the app does with events and entries it is handed. +/// It was written as the **inert-`.git` posture**'s byte-level proof (12-editions.md ▸ The free tier +/// and `.git`, as it then read: "any `.git` is inert … the app never reads history, never commits, +/// never touches `.git` in any way"). **PIVOT 2026-08-07** retired that posture outright — git left +/// the paywall, so a `.git` at a board root is *live* in every tier, detection runs at every open, +/// and the auto-committer writes into exactly the directory this file's fixture plants. /// -/// This file states the **output** claim, which no existing test covers, and which is the one the -/// posture has to be able to demonstrate: a full session of ordinary editing leaves every byte and +/// The tests survive the retirement unchanged because none of them ever composed a `HistoryStore` or +/// asserted anything about a tier: they drive `BoardWriter` and `BoardLoader` directly, and what they +/// pin is that **those layers are git-agnostic**. That claim is not only still true, it is now +/// load-bearing in two places the retired posture never reached: +/// +/// - **A board nobody added git to** (mode `none`) is the one board the app manages no git for, for +/// good — git stays opt-in per board (06 ▸ Rules; 13-native-undo.md's header read through the +/// pivot). Nothing but this layer runs on such a board, so this layer's indifference *is* the +/// whole promise. +/// - **A `.git` the app does not manage** — a repo-nested board's ancestor, a clone dropped inside a +/// card folder (which the fixture below plants deliberately) — is left strictly alone by the same +/// indifference, whatever the board's own mode or the user's tier. +/// +/// Two neighbouring claims are pinned elsewhere and are referenced, not repeated: `FolderWatcherTests` +/// ▸ ".git filtering" proves the watcher ignores churn under a `.git` at any depth, and +/// `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a `.git` at the +/// board root loads as an ordinary stray with no warning. Both are *input* claims: what the app does +/// with events and entries it is handed. +/// +/// This file states the **output** claim: a full session of ordinary editing leaves every byte and /// every mtime under `.git` exactly as it found them. It is asserted the only way /// that is worth anything — by snapshotting the whole `.git` subtree from the filesystem before /// the edits and re-reading it afterwards, never through the app's own read path @@ -109,9 +122,11 @@ private func makeGitDirectory(in fixture: WriterFixture, under parent: String) t } /// The board every test here edits: a root, two lanes, three cards, a `.git` at the board root -/// **and** a second one nested inside a card folder (12: "any `.git` is inert", not just the -/// root's own — a repo-nested board or a clone dropped inside a card is the same promise). -private struct InertGitBoard { +/// **and** a second one nested inside a card folder. Both, deliberately: the nested one is a `.git` +/// the app never manages under any ruling (a clone dropped inside a card), and the root one is the +/// case the pivot changed — it is a live repository to the *git* layer now, and still nothing at all +/// to the file layer these tests drive. +private struct UntouchedGitBoard { let fixture: WriterFixture let laneA: String let laneB: String @@ -144,12 +159,13 @@ private struct InertGitBoard { func tearDown() { fixture.tearDown() } } -// MARK: - The posture +// MARK: - The claim -struct InertGitTests { +@Suite("The file layer never touches `.git`") +struct UntouchedGitTests { @Test("A full session of ordinary edits leaves every byte and mtime under .git untouched") func anEditingSessionNeverTouchesGit() throws { - let board = try InertGitBoard() + let board = try UntouchedGitBoard() defer { board.tearDown() } let before = try snapshotSubtree(board.root, ".git") @@ -205,7 +221,7 @@ struct InertGitTests { @Test("The board loads and renders normally with a .git at its root, without a warning") func aGitBearingBoardLoadsLikeAnyOther() throws { - let board = try InertGitBoard() + let board = try UntouchedGitBoard() defer { board.tearDown() } let before = try snapshotSubtree(board.root, ".git") @@ -223,7 +239,7 @@ struct InertGitTests { @Test("A nested .git rides along a card move byte- and mtime-verbatim") func aNestedRepositorySurvivesACardMove() throws { - let board = try InertGitBoard() + let board = try UntouchedGitBoard() defer { board.tearDown() } let nested = "\(board.laneA)/\(board.cardWithRepo)/.git" @@ -250,7 +266,7 @@ struct InertGitTests { @Test("A copy of a card carrying a .git reproduces it verbatim and leaves the original alone") func aCopyCarriesTheNestedRepositoryWithoutTouchingTheOriginal() throws { - let board = try InertGitBoard() + let board = try UntouchedGitBoard() defer { board.tearDown() } let nested = "\(board.laneA)/\(board.cardWithRepo)/.git" diff --git a/KanbanTests/RepositoryHygieneTests.swift b/KanbanTests/RepositoryHygieneTests.swift index 8e5733d..beb531d 100644 --- a/KanbanTests/RepositoryHygieneTests.swift +++ b/KanbanTests/RepositoryHygieneTests.swift @@ -49,7 +49,7 @@ private func churn(_ fixture: WriterFixture, commits: Int) throws { // MARK: - Filesystem instruments -/// One entry under a subtree: path, bytes (nil for directories), and mtime — `InertGitTests`' +/// One entry under a subtree: path, bytes (nil for directories), and mtime — `UntouchedGitTests`' /// instrument. Bytes alone would pass a rewrite with identical content; the mtime is the assertion /// that nothing opened the file for writing at all. private struct SubtreeEntry: Equatable, CustomStringConvertible { @@ -169,7 +169,7 @@ struct GitignoreSeedTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) // The file, and the whole of the file — the one seed text, shared with board creation and @@ -192,7 +192,7 @@ struct GitignoreSeedTests { try fixture.file(".DS_Store", Data([0x00, 0x01, 0x42])) try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x43])) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) // Not "removed from history later" — never in it. The app has no history-rewriting operation @@ -211,7 +211,7 @@ struct GitignoreSeedTests { try fixture.file(".gitignore", mine) let before = try snapshot(fixture.root, ".gitignore") - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) // Not merged, not appended to, not reordered — and not even opened for writing, which is @@ -226,7 +226,7 @@ struct GitignoreSeedTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) // The user edits it — including deleting the line the app seeded, which is their business. @@ -251,7 +251,7 @@ struct GitignoreSeedTests { func theCommitterObeysTheSameFile() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) // The user fine-tunes their own noise definition, which is exactly what the file is for. @@ -276,7 +276,7 @@ struct GitignoreSeedTests { // `git init` run outside the app, exactly the shape a cloned or hand-inited board arrives in. _ = try Repository.create(at: fixture.root) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(git.mode == .git) #expect(!fixture.exists(".gitignore"), "composing history is not a write") } @@ -294,7 +294,7 @@ struct GitignoreSeedTests { try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) - let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) + let git = HistoryStore.compose(boardRoot: boardRoot) #expect(await git.addGit() == false) #expect(git.mode == .repoNested) #expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".gitignore").path)) @@ -485,7 +485,7 @@ struct GitHousekeepingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) try churn(fixture, commits: 4) @@ -532,7 +532,7 @@ struct GitHousekeepingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) try churn(fixture, commits: 3) @@ -554,7 +554,7 @@ struct GitHousekeepingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) try churn(fixture, commits: 3) @@ -574,7 +574,7 @@ struct GitHousekeepingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) let before = try snapshot(fixture.root, ".git") @@ -590,7 +590,7 @@ struct GitHousekeepingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) try churn(fixture, commits: 3) @@ -604,7 +604,7 @@ struct GitHousekeepingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) try churn(fixture, commits: 3) @@ -624,7 +624,7 @@ struct GitHousekeepingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) try churn(fixture, commits: 3) @@ -643,7 +643,7 @@ struct GitHousekeepingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) let cardPath = "\(Ident.lane1)/\(Ident.card1)/index.md" @@ -694,27 +694,19 @@ struct GitHousekeeperSchedulingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let plain = HistoryStore.compose(boardRoot: fixture.root) #expect(plain.housekeeper == nil, "no repository, nothing to maintain") #expect(await plain.addGit()) #expect(plain.housekeeper != nil, "the mid-session flip maintains itself like any git board") - let reopened = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let reopened = HistoryStore.compose(boardRoot: fixture.root) #expect(reopened.housekeeper != nil) - } - @Test("The free tier has no housekeeper anywhere, because it has no git state at all") - func theFreeTierMaintainsNothing() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - - let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) - #expect(await seed.addGit()) - - // Structural, not conditional: with no `HistoryStore` there is no housekeeper to disable and - // no code path that could reach one (12-editions.md ▸ The free tier and `.git`). - #expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil) + // The mode is the *whole* condition. A companion test used to sit beside this one pinning + // that the free tier maintained nothing anywhere — structurally, since `compose` answered + // `nil` off Pro — and PIVOT 2026-08-07 (12-editions.md) retired both the gate and the claim: + // there is no tier to compose under, so this suite's one axis is the one above. } @Test("A commit in flight defers the pass entirely — it is never retried") @@ -722,7 +714,7 @@ struct GitHousekeeperSchedulingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) try churn(fixture, commits: 3) @@ -749,11 +741,11 @@ struct GitHousekeeperSchedulingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let seed = HistoryStore.compose(boardRoot: fixture.root) #expect(await seed.addGit()) try churn(fixture, commits: 3) - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) let housekeeper = try #require(git.housekeeper) housekeeper.threshold = 1 housekeeper.delay = .milliseconds(20) @@ -768,7 +760,7 @@ struct GitHousekeeperSchedulingTests { // Teardown cancels an armed one, so a closed board's maintenance cannot fire against a store // that has gone. - let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let second = HistoryStore.compose(boardRoot: fixture.root) let secondKeeper = try #require(second.housekeeper) secondKeeper.threshold = 1 secondKeeper.delay = .milliseconds(200) @@ -783,7 +775,7 @@ struct GitHousekeeperSchedulingTests { let fixture = try makeBoard() defer { fixture.tearDown() } - let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + let git = HistoryStore.compose(boardRoot: fixture.root) #expect(await git.addGit()) let housekeeper = try #require(git.housekeeper) diff --git a/KanbanUITests/AccessibilityAuditTests.swift b/KanbanUITests/AccessibilityAuditTests.swift index d7124a0..45c0f2f 100644 --- a/KanbanUITests/AccessibilityAuditTests.swift +++ b/KanbanUITests/AccessibilityAuditTests.swift @@ -11,17 +11,16 @@ import XCTest /// currently displayed UI** rather than a subtree, so each test's job is entirely navigation: get the /// surface on screen, then let the audit look at whatever is there. /// -/// ### The one surface this suite cannot reach, and why it is not fixable here +/// ### The board settings sheet is reachable since the 2026-08-07 pivot /// -/// The **board settings sheet** is Pro-only (03-board-ui.md ▸ Board settings sheet; -/// `BoardSettingsAvailability`), and the fixture launch has no tier control: `AppModel.currentTier` -/// reads `ProEntitlement`, which reads StoreKit, and there is deliberately **no launch argument that -/// grants Pro**. Adding one would be worse than the gap it closed — `UITestLaunch` is compiled into -/// the shipping binary on purpose ("the thing being audited must be the app that ships"), so a -/// `--ui-test-pro` flag would be a subscription bypass anyone could type into Terminal. So the sheet's -/// own audit is **deferred to the manual VoiceOver pass** (`AccessibilityVerification.md`), and what -/// is automated here instead is the posture that *is* reachable: the free tier's, where the row exists -/// and is disabled. See `testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture`. +/// The sheet was Pro-only, and this suite could not reach it: the fixture launch had no tier +/// control, and a `--ui-test-pro` launch argument was rejected as a subscription bypass anyone could +/// type into Terminal (`UITestLaunch` ships in the app binary on purpose). Since the pivot +/// (12-editions.md ▸ PIVOT 2026-08-07 — git left the paywall), `BoardSettingsAvailability` asks only +/// the board's mode, the fixture board is mode `none`, and the sheet opens for the audit like any +/// other surface — the deferral to the manual VoiceOver pass is retired with the gate. The +/// no-bypass objection stands as precedent for whatever the next split gates. See +/// `testBoardSettingsSheetOpensFromTheMenuAndAudits`. /// /// ### No waiving /// @@ -203,22 +202,12 @@ final class AccessibilityAuditTests: XCTestCase { // MARK: - The board settings sheet - /// **The reachable half of the settings sheet's audit** — the free tier's, which is the tier the - /// fixture launch runs in (see this file's header for why there is no Pro fixture and why adding - /// one would be a subscription bypass). - /// - /// What it pins is the design's deliberate asymmetry between the sheet's two doors - /// (`BoardSettingsAvailability`): the **menu row stays visible and disabled** where the sheet - /// cannot exist — standard menu validation, and 11-command-nexus.md's "a command absent here - /// doesn't exist" read in the other direction — while the popover's row is *absent* there instead. - /// A row that vanished from the menu on the free tier would be the app hiding its own inventory; - /// one that was enabled would present an empty sheet. - /// - /// The audit call is the board's, taken with the Board menu closed again: the sheet is what this - /// test cannot open, so auditing the surface that *is* on screen keeps the test honest about what - /// it checked. + /// **The settings sheet's audit**, reachable since the 2026-08-07 pivot (this file's header): + /// the fixture board is mode `none`, `BoardSettingsAvailability` asks only the mode, so the menu + /// row opens the sheet — add-git's home (03-board-ui.md ▸ Board settings sheet) — and the audit + /// looks at the sheet itself rather than deferring to the manual VoiceOver pass. @MainActor - func testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture() throws { + func testBoardSettingsSheetOpensFromTheMenuAndAudits() throws { let app = XCUIApplication.launchedWithFixtureBoard() let bar = app.menuBars.firstMatch @@ -232,14 +221,21 @@ final class AccessibilityAuditTests: XCTestCase { let row = bar.menuItems["Board Settings…"] XCTAssertTrue( row.waitForExistence(timeout: XCUIApplication.uiTimeout), - "Board ▸ Board Settings… is missing — the row ships whether or not this board can open it" + "Board ▸ Board Settings… is missing — the row ships in every mode that can host the sheet" ) - XCTAssertFalse( + XCTAssertTrue( row.isEnabled, - "the free tier has no board settings sheet, so the row must be disabled rather than open an empty one" + "a mode-none board hosts the sheet (add-git's home) — the pivot retired the tier gate that disabled this row" ) - app.typeKey(.escape, modifierFlags: []) + row.click() + let sheet = app.sheets.firstMatch + XCTAssertTrue( + sheet.waitForExistence(timeout: XCUIApplication.uiTimeout), + "the Board Settings sheet did not present from its menu row" + ) try app.performAccessibilityAudit() + + app.typeKey(.escape, modifierFlags: []) } }