The popover/sheet split reverses — settings fold into the Git tab, the widget stacks name over branch

The titlebar widget becomes a two-line identity block: the board glyph at
22pt spanning both lines, the title over the branch (git-mode only, smaller
and secondary), the em-dash retired. New Branch… returns to the switch menu
behind a divider, revealing an inline name field — the pre-split shape. The
board settings sheet retires whole: add-git and commit identity render
inline in the Git tab's postures (BoardGitSetup.swift), the availability
rule collapses into BoardGitSetupSection.resolve, and Board ▸ Board
Settings… leaves the menu bar. Where 07's remote/credential setup surfaces
land is deliberately left open — filed on the Redesign board.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 21:48:02 -04:00
parent 99ebb69a1d
commit 7414fc8400
22 changed files with 884 additions and 1259 deletions
+13 -7
View File
@@ -63,21 +63,27 @@ The board's vital statistics, read-only, in two registers with one honest split:
### Git tab (settled 2026-08-07)
The pre-tab closing git section rehomed whole — **the daily face, and only that** (the 2026-07-31 popover/sheet split stands; a repository-facts dossier in the Info register was considered this session and declined — the popover's git surface is for operating, and per-item history is the card History section's). The postures (06-history-undo.md ▸ Rules; 12-editions.md) render one tab surface each, with the tab's own label doing the work the section's "Git" header used to:
The pre-tab closing git section rehomed whole — and, later the same day, **the retired settings sheet's contents with it**: this tab is where a board's repository is both operated and set up (a repository-facts dossier in the Info register was considered this session and declined — the popover's git surface is for operating, and per-item history is the card History section's). The postures (06-history-undo.md ▸ Rules; 12-editions.md) render one tab surface each, with the tab's own label doing the work the section's "Git" header used to:
- **No repository** → one caption stating the fact above the **Board Settings…** door — the header-plus-door posture blessed 2026-08-06, restated for a surface whose header is now the tab label; the door stands exactly where a user looking for git will look. *(Pivot 2026-08-07 — 12: this is every tier's posture now, and git stays opt-in per board: the door is an offer, never an auto-init.)*
- **Repo-nested** and **unverifiable** → their settled prose, no door (nothing setup-shaped can apply).
- **Git mode** → the branch display with the **switch picker**, the abnormal-state notes (paused, unreadable, switch failure — 06), and the **Board Settings…** door; on remote-backed boards, remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error — the badge points at the sheet, capture happens there) join as one more block under the branch controls (07-sync-collab.md's cards, unchanged by the rehome).
- **No repository** → one caption stating the fact, then **add-git** directly under it — the fact-then-offer posture blessed 2026-08-06, whose middle term was a **Board Settings…** door for the week the sheet existed *(amended 2026-08-07: the split reversed, so the offer is the control itself again)*. *(Pivot 2026-08-07 — 12: this is every tier's posture now, and git stays opt-in per board: the button is an offer, never an auto-init.)*
- **Repo-nested** and **unverifiable** → their settled prose, no action (nothing setup-shaped can apply).
- **Git mode** → the branch display with the **switch picker**, the abnormal-state notes (paused, unreadable, switch failure — 06), and a **Commit Identity** block under a heading VoiceOver navigates by *(rehomed 2026-08-07 from the sheet; withheld when the repository won't open, since writing an identity is a write into a repository the app can't open — the branch surface's own unreadable sentence stands alone there)*; on remote-backed boards, remote tracking (ahead/behind) with **Pull/Push** controls and the status badges (Authentication needed, queued pushes, last error) join as one more block under the branch controls (07-sync-collab.md's cards, whose own setup half needs a home now that the sheet is gone — open).
- **Free + inert `.git`** and **absent***retired by the 2026-08-07 pivot (12: git left the paywall)*: the Pro pointer described a gate that no longer exists, and with no free-only postures every board resolves one of the three families above, so the tab is always in the strip.
**A single-branch board's picker opens onto a disabled explanatory row** (ruled 2026-08-06, built with the tab): with creation relocated to the sheet, the menu holds only the *other* local branches, and an empty menu reads as broken — a disabled "No other branches" row teaches both why the menu is empty and where creation went. The Board Settings… row remains the popover's one setup affordance, shown only where the sheet is reachable; each control keeps exactly one home across popover and sheet.
**Branch creation is the switch menu's again** *(2026-08-07, reversing the 2026-07-31 relocation to the sheet)*: below the switch targets sits a divider and a **New Branch…** entry that reveals an inline name field with Create under the branch row — the shape creation had before the split, restored when the sheet retired. Escape steps outward one layer per press (a dirty field clears, an empty one closes the reveal, then the popover dismisses); create-and-switch runs 06's identical settle sequence, and its failures answer at the section's own caption.
The window-title widget opens the **board popover** — the one board-level surface. Its header hosts, on every board:
**A single-branch board's picker opens onto a disabled explanatory row** (ruled 2026-08-06, built with the tab): the menu's upper half holds only the *other* local branches, and an unexplained gap above the divider reads as a menu that lost something — a disabled "No other branches" row says why there is nothing to pick. *(Amended 2026-08-07: the row taught a second thing while creation lived in the sheet — that creation was no longer here — and that half retires with the reveal's return.)* The popover is now the one configuration home; each control keeps exactly one home within it.
The window-title widget opens the **board popover** — the one board-level surface. **The widget is a two-line identity block** (reworked 2026-08-07): the board's glyph at roughly double its drawn text height, sized to span both lines, beside a stack whose first line is the board's name (titlebar weight, the string the window title would show) and whose second — **git-mode boards only** — is the current branch, smaller and secondary; then the disclosure chevron. It was one line reading `glyph Name — branch ⌄`, and the em-dash retired with the rework: a separator was doing a hierarchy's job, and the branch was competing for width with the name it qualifies. Both lines truncate at the tail inside the widget's 400pt cap; a board with no branch is the same block with one line, centered against the same glyph. Its header hosts, on every board:
- **Board rename** (settled: this function stays in-app, unlike the pathfinder which dropped it with the inspector). Rename edits the board's frontmatter `title` only — the folder is never renamed by the app; the Finder document name is Finder's to change (01-storage-format.md's board-naming rule). A foreign rename landing while the popover is open resyncs the field from the snapshot only while the field is unfocused — a focused field keeps the user's keystrokes, the dirty-buffer courtesy applied here (settled).
- **The board glyph** — the symbol picker with its tint row beside the rename field owns the board's `icon`/`iconColor` (Styling ▸ Controls above); manual board styling beyond the glyph is Style… ⌥⌘S with nothing selected, and the Theme tab owns the preset backgrounds.
## Board settings sheet
## Board settings sheet — RETIRED 2026-08-07
**The surface is gone, and the 2026-07-31 popover/sheet split with it.** The split's promise was one home per control across two committed surfaces; a week of it showed the cost — setup a user could only reach through a door, a second surface whose existence had to be validated before either door could point at it, and a menu command that did nothing but open it. So the popover is the board's **one configuration home** again: **add-git** and **commit identity** render inline in the Git tab's postures (▸ Git tab above), **branch creation** went back into the switch menu's New Branch… reveal, the sheet's **availability rule** retires with the surface it gated, and **Board ▸ Board Settings…** leaves the menu bar (11-command-nexus.md). Board Info ⌘I is the door to all of it. The mechanical arguments the split rested on stand as *unfinished business*, not as a case for the sheet: 07-sync-collab.md's credential and SSH surfaces still want confirmation alerts, inline network probes and drag-in key import, and where those live is that card's to rule — the popover is not obviously wrong for them (an alert can present over it), but nothing here decides it.
The retired ruling, kept for its reasoning:
**The setup home** (ruled 2026-07-31 — the popover/sheet split, 04-interactions.md's configuration carve-out): a board-scoped, titled, sectioned sheet on the board window, opened from the popover's Board Settings… row and from Board ▸ Board Settings… (11-command-nexus.md). It hosts everything setup-shaped: **add-git** (mode none; opt-in init — 06), **add/change remote** with the inline verify probe (07 ▸ Setup verifies right there), **credentials** — HTTPS username/token fields and the whole SSH surface (machine key Copy + Verify, key import by paste or **drag** — the sheet's stable frame is part of why it exists — the per-host key picker, unreferenced-import removal, confirm-gated machine-key regeneration), the TOFU first-connect confirm and mismatch block, **commit identity** name/email (06 — the visibility-scoped 2 s config re-read rides with the fields), **branch creation** (switching stays in the popover; create-and-switch runs 06's identical settle sequence from here), and **push-on-commit**. The mechanics that forced the split live comfortably here: confirmation alerts present over the sheet without dismissing the flow that owns them, network probes and their spinners survive focus changes, and typed-but-unverified credentials are never discarded by a stray click. Under the read-only lock the sheet's mutating controls disable in place (the Style-popover rule); every control is Tab-reachable and labeled (10-accessibility.md). Each control has exactly one home — the popover never duplicates a sheet control, the sheet never hosts the daily surface.
+1 -1
View File
@@ -54,7 +54,7 @@ Every command is a menu item. The full inventory — every command and action, i
- **⌥⌘↑/⌥⌘↓ sort within the lane** (the move-vs-jump question, resettled: *card* moves live on the ⌥⌘ chord, joining ⌥⌘←/⌥⌘→ lane width in a "⌥⌘ modifies" family; plain ⌥-arrows stay jumps; plain ⌘↑/⌘↓ are unassigned): the selected card(s) move one position within the lane — logical `order`, across interior masonry columns (10-accessibility.md's logical-order rule). A non-contiguous multi-selection **gathers on the first press**: the cards collect into a contiguous block anchored at the first selected card (first = lowest logical order; the rest follow in preserved relative order), and subsequent presses move the block one position. **Cards never change lanes by ⌘-arrow** (settled): inter-lane movement is drag or Cut/Paste (the clipboard rules above), so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes and ⌘←/⌘→ are inert on card selections. With a **lane** selected, ⌘←/⌘→ move the lane one slot — closing 10-accessibility.md's lane-move defect — and ⌥⌘↑/⌥⌘↓ are inert.
- **⌫/⌘⌫ delete** (resettled 2026-07-28; lanes rejoined 2026-07-29): on cards *and lanes*, a move into the trash (`.trash/`, top position — 03-board-ui.md; a lane travels subtree-intact, `kind: lane` stamped when absent, no dialog — recoverable now, so nothing needs confirming); on a **trash** selection the same chord deletes **permanently** (one Delete vocabulary, staged by place — confirmation per 03's recoverability rule, a lane's alert counting its cards, a mixed trash selection's alert counting both kinds). Selection moves to the deleted item's successor sibling, Finder-style (next card in the lane, next lane on the board; the last sibling's predecessor otherwise; empty container = nothing selected) — repeated ⌫ walks down a lane. **In the trash the successor walk is kind-blind** (ruled 2026-07-31): the next row of either kind, in the same all-rows order plain arrows walk — a successor is a fresh singleton selection, so the landing violates no grammar, and repeated ⌘⌫ empties a mixed trash without dead-ends, each delete confirm-gated per its kind. Deliberate deletes pick a successor; *external* vanishing never does (02-architecture.md's reload-survival rule: the selection just shrinks). **Put Back is retired with the tombstone model** (resettled 2026-07-28): File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent routing; restore is drag-out or ⌘X/⌘V (The trash below). Plain ⌫ performs the same delete as fixed grammar (see Grammar above) — there is no Edit ▸ Delete item, so the two Delete-titled homes never collide for title-matched remapping.
- **Select All**: all visible cards on the board — filter-respecting, like every surface (Search below). **On the active trash side it selects the trash** (resettled 2026-07-28): with the trash visible and a non-empty trash selection, Select All selects all visible trash cards; in every other state, all visible live cards — the container boundary decides which "all" is meant (The trash below).
- **The contract's one carve-out is configuration** (settled; containers re-ruled 2026-07-31 — the popover/sheet split): form-like configuration keeps exactly one home per control, split by weight across two committed homes. The **board popover** is the light surface — rename, styling, branch display and *switching*, ahead/behind with Pull/Push, the status badges; its keyboard path is Board Info (⌘I) plus Tab-reachable controls. **Setup lives in the board settings sheet** (03-board-ui.md ▸ Board settings sheet) — add git, add/change remote, credentials and the SSH surface, commit identity, branch *creation*, push-on-commit; its keyboard path is Board ▸ Board Settings… (11-command-nexus.md) plus Tab-reachable controls (10-accessibility.md's Full Keyboard Access). The split's reasons are mechanical, not aesthetic: setup flows fire confirmation alerts, run inline network probes, and accept drag-in key import acts that need a surface a stray click can't dismiss. Recurring remote *operations* stay under the contract: Board ▸ Pull and Board ▸ Push are menu items (no default chord, remappable; validation enables them only on remote-backed boards — 07-sync-collab.md).
- **The contract's one carve-out is configuration** (settled; containers re-ruled 2026-07-31 — the popover/sheet split — and **re-ruled back 2026-08-07**): form-like configuration keeps exactly one home per control, and that home is the **board popover** — rename, styling, branch display and *switching*, branch *creation*, add git, commit identity, ahead/behind with Pull/Push, the status badges; its keyboard path is Board Info (⌘I) plus Tab-reachable controls (10-accessibility.md's Full Keyboard Access). *(For a week, setup lived in a board settings sheet reached by Board ▸ Board Settings…; the split's mechanical argument — setup flows fire confirmation alerts, run inline network probes, and accept drag-in key import, acts that want a surface a stray click can't dismiss — did not survive the cost of a second validated surface for controls a user could otherwise reach directly. 03-board-ui.md ▸ Board settings sheet carries the retirement, and 07-sync-collab.md's credential and SSH surfaces are where the argument gets its next hearing.)* Recurring remote *operations* stay under the contract: Board ▸ Pull and Board ▸ Push are menu items (no default chord, remappable; validation enables them only on remote-backed boards — 07-sync-collab.md).
- **⌘N target rule** (settled): with a card selected, the new card is created in that card's lane, immediately after it (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); **a multi-selection anchors at its last member in flatten order** (settled — lane `order`, then card `order`, the multi-drag order; the same anchor serves paste): creation follows the last selected card, or appends to the last selected lane; the lane header's new-card button **overrides this rule** — the click names its target lane, selection notwithstanding (11-command-nexus.md ▸ Pointer grammar); with nothing selected — or a **trash** selection, which never anchors creation — the **last-active lane** — the lane that most recently held selection or a creation in this window session — falling back to the first lane. Title editor focused; same placeholder/abandon semantics as Return-creation. **Zero-lane board** (hand-made, or every lane deleted): card creation and card paste have no target — New Card, Return-creation, and Paste with a *card* payload disable via menu validation until a lane exists. New Lane (⇧⌘N) is one way in; Paste with a **lane** payload is the other — it stays enabled and lands at the board's right end (the lane-paste rule above), so cross-board structure transfer never needs a lane to exist first.
### The trash, keyboard-first (resettled 2026-07-28 — the materialized trash)
+8 -8
View File
@@ -2,13 +2,13 @@
**Tier scope: every tier** (Pivot 2026-08-07 — 12-editions.md: git left the paywall; this line formerly scoped the doc to Lanework Pro, with the free tier shipping mode:none only over the now-retired inert-`.git` posture). This doc is the git HistoryProvider, composed on git-mode boards in every tier; boards without app-managed git bind macOS-native undo (13-native-undo.md). The Undo routing section below was always tier-independent — both substrates dispatch through it.
Git is the undo substrate — on boards that have git. **Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later** (via the board settings sheet — 03-board-ui.md; see 07-sync-collab.md's mode progression). A board without app-managed git binds the **native undo stack in every tier** — repo-nested included (re-ruled 2026-07-31, twice — the provider follows the board, 13-native-undo.md; formerly no-undo under Pro, which made upgrading remove undo from mode-none boards, and the repo-nested no-undo residue retired the same day: the native stack touches no git, so leave-strictly-alone is untouched and no board lacks ⌘Z). (Text editors keep their standard typing undo everywhere; see Undo routing below.) Deletes — card or lane — are recoverable on every board via the materialized trash (03-board-ui.md). Add-git swaps native → git mid-session, discarding the in-session native stack and seeding the git trail — the branch-switch discard-and-reseed precedent. On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.
Git is the undo substrate — on boards that have git. **Git is opt-in per board (a pivot from the pathfinder, which auto-initialized every board): a board may be created without git, and git can be added later** (via the board popover's Git tab — 03-board-ui.md; see 07-sync-collab.md's mode progression). A board without app-managed git binds the **native undo stack in every tier** — repo-nested included (re-ruled 2026-07-31, twice — the provider follows the board, 13-native-undo.md; formerly no-undo under Pro, which made upgrading remove undo from mode-none boards, and the repo-nested no-undo residue retired the same day: the native stack touches no git, so leave-strictly-alone is untouched and no board lacks ⌘Z). (Text editors keep their standard typing undo everywhere; see Undo routing below.) Deletes — card or lane — are recoverable on every board via the materialized trash (03-board-ui.md). Add-git swaps native → git mid-session, discarding the in-session native stack and seeding the git trail — the branch-switch discard-and-reseed precedent. On git-enabled boards, every settled change auto-commits; those mechanics are carried over from the pathfinder with their hard rules intact.
## Rules
- **Opt-in init**: adding git to a board initializes a local repo at the board root. Bundled libgit2 — no git install required. No silent auto-init, ever. **The initial branch is `main`** (blessed 2026-07-31): the host's `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it deterministically — git's modern default, the pathfinder's choice.
- **Adoption**: a board whose root already contains `.git` opens **in git mode, silently** — adoption is not init. The no-silent-auto-init rule forbids *creating* a repository the user didn't ask for; recognizing one that exists is the opposite of that: the repo's presence *is* the opt-in (someone ran `git init` or `git clone`), and this is the primary way a second machine joins a shared board — clone in a terminal, open in the app (07-sync-collab.md's second entry arrow). All git-mode behavior applies from the first open: auto-commit, undo reseeded from the existing HEAD's first-parent ancestry, remote tracking if a remote is configured.
- **Detection is nearest-`.git`-wins**, checked at every board open: `.git` at the board root → git mode (adoption above); no `.git` at the root but one at any ancestor → repo-nested (below); neither → mode none. **Denial is not absence** (ruled 2026-07-31): the ancestor walk crosses paths above the board's sandbox grant, and a check the sandbox *refuses* (EACCES/EPERM) must never read as "no repo there" — detection distinguishes **clean none** (every ancestor answered not-found) from **unverifiable** (a check was denied); add-git is offered only on clean none, and unverifiable takes the repo-nested posture (conservative — the popover explains rather than offers). As hardening, add-git's create re-runs full detection and refuses unless it reads clean none, so the forbidden nested init is impossible even on a raced or stale read. Whether the shipped sandbox actually denies ancestor stats is an open empirical question (manual-verification list: a board deep inside an ungranted repo, and an ordinary board under an ungranted parent) — if it denies everywhere, every board would read unverifiable and this posture needs a data-informed revisit. A board can therefore change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects what it finds. **Open-time only, deliberately — for *discovery***: a `git init` under an open mode-none board takes effect at the next open — the running session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session mode flips from watching; stated here so it isn't rediscovered as a bug). The one deliberate mid-session transition is the app's own **add-git** (Opt-in init above): clicking it flips the open board into git mode immediately — the settings sheet flows straight into the git sections, the first auto-commit follows — the rule forbids *discovered* flips, never commanded ones.
- **Detection is nearest-`.git`-wins**, checked at every board open: `.git` at the board root → git mode (adoption above); no `.git` at the root but one at any ancestor → repo-nested (below); neither → mode none. **Denial is not absence** (ruled 2026-07-31): the ancestor walk crosses paths above the board's sandbox grant, and a check the sandbox *refuses* (EACCES/EPERM) must never read as "no repo there" — detection distinguishes **clean none** (every ancestor answered not-found) from **unverifiable** (a check was denied); add-git is offered only on clean none, and unverifiable takes the repo-nested posture (conservative — the popover explains rather than offers). As hardening, add-git's create re-runs full detection and refuses unless it reads clean none, so the forbidden nested init is impossible even on a raced or stale read. Whether the shipped sandbox actually denies ancestor stats is an open empirical question (manual-verification list: a board deep inside an ungranted repo, and an ordinary board under an ungranted parent) — if it denies everywhere, every board would read unverifiable and this posture needs a data-informed revisit. A board can therefore change mode between opens (e.g. the user ran `git init` in a terminal) — the app just reflects what it finds. **Open-time only, deliberately — for *discovery***: a `git init` under an open mode-none board takes effect at the next open — the running session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session mode flips from watching; stated here so it isn't rediscovered as a bug). The one deliberate mid-session transition is the app's own **add-git** (Opt-in init above): clicking it flips the open board into git mode immediately — the tab flows straight from the add-git offer into the git-mode posture, the first auto-commit follows — the rule forbids *discovered* flips, never commanded ones.
- **A `.git` that isn't a valid repository still reads as git mode — and fails loudly** (ruled 2026-07-31): detection is presence-shaped (any root `.git` entry, directory or worktree/submodule pointer file), so a corrupt or unopenable repo never falls to mode none — Add Git is never offered against an existing `.git`, whatever its condition (init into a repairable repo is exactly the never-mutate hazard). The board itself loads and edits normally — files are the board — but the failure is **loud**: a standing breakage-class banner at detection ("This board's git repository can't be read — history is paused; Lanework leaves the repository untouched"), announced per 10-accessibility.md, with the whole git surface paused (the abnormal-states posture below) and the popover's git section naming the state; the banner clears when a later open or reload finds the repo readable. Never a silent placeholder discovered only in the popover.
- **Abnormal repo states** (settled; adoption never assumes a tidy clone): an **unborn HEAD** (`git init`, no commits yet) is normal git mode — the first auto-commit creates the root commit on the branch HEAD names, and the undo trail simply starts empty. **The root commit has its own subject** (settled): whenever the app creates a repo's first commit — immediately on the app's own add-git (init doesn't wait for the debounce; the board is protected from the moment git exists), or at the first settled change on an adopted unborn repo — it commits the whole tree as **"Initial board state"**, never a folded diff-from-empty: there is no last-committed snapshot to diff against, and forty Adds would bury the event. **The root commit is never split and is user-authored** (blessed 2026-07-31): it is a baseline, not a change-set — the event it records is the user's act of putting the board under git, adopted unborn repos' unwitnessed files included. A **detached HEAD**, or an **in-progress merge/rebase/cherry-pick** left by outside-the-app git (`MERGE_HEAD`, `rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD` — pause states that load fine on a clean tree and are otherwise invisible), instead **pauses the git surface honestly — the *whole* surface, remote half included** (settled): auto-commit holds (the auth-pause posture, 07-sync-collab.md — pause, badge, explain, never hammer), Undo/Redo and the branch controls disable, **and Pull, Push, and push-on-commit hold with them** — with auto-commit held, 07's clean-tree-by-pull-time invariant is false, and a pull's rebase (or a rejected push's fetch→rebase→push) would run against a dirty tree carrying uncommitted edits, precisely what flush-before-overwrite exists to prevent; the ahead/behind badge keeps counting (a fetch is a read), and the popover's git section names the state plainly ("HEAD is detached — commits would belong to no branch"; "a merge is in progress") and says resolving it belongs to the tool that created it. Edits keep landing on disk — files are the board — and commit as one settled batch when the state clears. The app **never mutates repo state it didn't create** (no auto branch-at-HEAD, no `merge --abort`); the check runs at open and again before every flush — and, because a terminal's cleanup moves only files under `.git`, which the watcher never delivers, **a standing pause re-reads the repository state every 15 s** (blessed 2026-07-31; injectable cadence, only while paused, a handful of stats — not a retry, nothing is attempted) — so finishing the operation in a terminal resumes the pipeline without ceremony. **The one exemption is the app's own leftovers** (settled): every bracketed operation stamps its intent app-side (per-board registry) before touching the repo, so an interrupted app-run rebase or checkout is recognizable as Lanework's — finding a pause state with a matching stamp, the app **aborts its own unfinished operation** to restore the pre-operation state and says so via banner ("a branch switch was interrupted — the previous state is restored"), then clears the stamp — **on success only; a failed abort keeps the stamp** (blessed 2026-07-31): the stamp is the sole evidence the leftover is the app's, and clearing it on failure would demote the leftover to somebody-else's forever — the pause would then send the user to "the tool that created it," which was this app. Kept, the next open or paused-state re-read recognizes it and retries; recovery converges instead of orphaning. Abort discards nothing: fetched commits stay fetched, local commits are restored — the rebase's own no-loss accounting. Without a matching stamp the leftover is outside git's, and the pause-and-defer stance above holds unchanged.
- **Boards nested inside an existing repository are left strictly alone** — git cannot be added to them (no nested repo, no commits into the user's repo), so they get **no app-managed history — while the native session stack still serves ⌘Z there in every tier** (re-ruled 2026-07-31, retiring the old no-undo residue: 13's stack is memory-only and journal-free, so self-containment holds; what repo-nested denies is git, never undo). The board popover's git section must say so honestly: not a hidden "add git" but a short explanation ("this board lives inside a repository; Lanework leaves it to that repository") — the option is absent because it *can't* apply, and the UI should teach that rather than look broken.
@@ -21,11 +21,11 @@ Git is the undo substrate — on boards that have git. **Git is opt-in per board
- **Undo survives relaunch**: the undo stack reseeds from HEAD's first-parent ancestry on load; redo starts empty. In-session it behaves as classic dual stacks; after relaunch, past restore commits reappear as ordinary undoable steps. Deliberate: no sidecar state, nothing ever lost. Interaction with pull (07-sync-collab.md): a pull rebases unpushed local commits, so the in-session stack must remap onto the rewritten commits — the pre-rebase hashes are orphaned. A pleasant consequence of the reseed rule: the fetched remote commits sit in HEAD's first-parent ancestry, so after the next relaunch remote work becomes ordinary undoable steps too.
- **Heal commits are transparent to undo, in-session** (ruled 2026-07-29): heal-class commits — their paths known by the Writer's heal-marked receipts (Commit messages below) — never become undo steps: the stack pointer passes over them, and a restore materializing an older target **excludes paths whose divergence is heal work**, so a ⌘Z run never reverts a repair and never summons the scheduler (reverting one would re-arm the memo on the recreated defect signature, land a fresh heal commit, and — redo cleared — trap the run on an ever-renewing top; transparency dissolves the trap instead of suppressing the healer). The in-session qualifier is honest: receipts live in memory and the reseed is deliberately sidecar-free, so after relaunch old heal commits reappear as ordinary steps — undoing one recreates its defect and the scheduler re-heals within a reload, restore commit plus fresh heal commit, notice included. That **residual bounce is accepted family-wide** — the agent-guide quirk (Agent collaboration below) generalized to the relocation, the legacy migration, the displacement, and the remint — and it is **self-limiting to one bounce**: the fresh heal commit is in-session, transparent, and the undo run continues past it. Redo is symmetric.
- **Undo is board-local.** A cross-board move-out undone at the source resurrects the card even though it lives on in the destination — per-board histories cannot and must not mutate other boards. The resulting same-UUID fork across boards is legitimate (boards are independent identity namespaces); if the two ever meet through a move-in, the import boundary remints the arrival (01-storage-format.md's identity lifecycle).
- The git surface splits across the **board popover** and the **board settings sheet** (03-board-ui.md; the 2026-07-31 popover/sheet split): the popover carries the daily face — branch/source display, the switch picker, posture lines — alongside board rename and styling; the sheet carries setup — add-git, branch creation, the commit-identity name/email fields (see Interaction with external writers below), remote and credentials (07).
- The git surface is the **board popover's Git tab**, whole (03-board-ui.md): branch/source display, the switch picker with its New Branch… reveal, the posture lines, add-git, and the commit-identity name/email fields (see Interaction with external writers below), alongside board rename and styling in the popover's other tabs. *(Amended 2026-08-07 — the 2026-07-31 popover/sheet split is reversed: setup lived in a board settings sheet for a week, and that surface retired with its menu command; 03 ▸ Board settings sheet carries the reasoning. Remote and credentials — 07 — need a home ruled on that card.)*
## Undo routing
**Routing is by focus** — the platform's first-responder rule, its own section because two undo systems coexist and four docs cite the rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on **every** board — and since the 2026-07-31 repo-nested re-ruling, so does board-level undo: every board binds a provider (native or git), so "no undo" is no longer a state any board is in. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search), the popover's rename field, and the settings sheet's text fields (commit identity, credentials, remote URL) own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to git while a text-bearing control has focus; a reflexive undo over a typo must never become a tree checkout. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are, **in a card window, that window's own session stack** (13-native-undo.md's two-level model, re-ruled 2026-07-31 — fine-grained window gestures, both tiers; the coarse close unit is the tier-split: one native board step, or one commit), and on board surfaces board history — the board's bound provider, git or native (every board binds one since 2026-07-31; disabling is locks and empty stacks). **No fall-through**: exhausting a focused editor's — or the window's — stack beeps; it never reaches board history.
**Routing is by focus** — the platform's first-responder rule, its own section because two undo systems coexist and four docs cite the rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is the git trail. Text undo works on **every** board — and since the 2026-07-31 repo-nested re-ruling, so does board-level undo: every board binds a provider (native or git), so "no undo" is no longer a state any board is in. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search), the popover's rename field, and the popover's own configuration fields (commit identity, the New Branch… name, and 07's credentials and remote URL when they land) own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to git while a text-bearing control has focus; a reflexive undo over a typo must never become a tree checkout. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are, **in a card window, that window's own session stack** (13-native-undo.md's two-level model, re-ruled 2026-07-31 — fine-grained window gestures, both tiers; the coarse close unit is the tier-split: one native board step, or one commit), and on board surfaces board history — the board's bound provider, git or native (every board binds one since 2026-07-31; disabling is locks and empty stacks). **No fall-through**: exhausting a focused editor's — or the window's — stack beeps; it never reaches board history.
## Commit messages
@@ -46,9 +46,9 @@ The pathfinder's message engine carries over as the model — it is what earns t
## Branch switching
Switching a branch from the popover's picker (or creating-and-switching from the settings sheet the 2026-07-31 split) — **create-and-switch runs the identical settle → flush → stamp → switch sequence, no at-HEAD fast path** (blessed 2026-07-31): "creating at HEAD can't change the tree" fails as a proof under concurrent writers (a foreign commit can land between the check and the create), and flushing pending work onto the branch it was made on is the honest cadence regardless — the occasional save-or-discard beat on a create buys one sequence with no special case:
Switching a branch from the popover's picker, or creating-and-switching from the **New Branch…** reveal behind that picker's divider (03-board-ui.md ▸ Git tab; the field moved to the settings sheet with the 2026-07-31 split and came back with the 2026-08-07 reversal, the sequence untouched by either move) — **create-and-switch runs the identical settle → flush → stamp → switch sequence, no at-HEAD fast path** (blessed 2026-07-31): "creating at HEAD can't change the tree" fails as a proof under concurrent writers (a foreign commit can land between the check and the create), and flushing pending work onto the branch it was made on is the honest cadence regardless — the occasional save-or-discard beat on a create buys one sequence with no special case:
- **Settle the editors first — explicitly, never silently.** Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a **save-or-discard step**: **Save All** ends every session with its normal commit (each card's Edit→Preview flip), **Discard** reverts buffers and uncommitted saves to HEAD, **Cancel** keeps the current branch and the sessions. **Open raw-source buffers are settled by the same step** (settled — an unsettled raw buffer is the worse hazard: its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new branch's card): Save All *applies* each raw buffer — and since Apply validates, a buffer that fails validation cancels the whole switch with focus on the offending window, nothing half-switched; Discard exits raw source without writing; Cancel keeps everything. External checkouts the app can't gate are the accepted last-writer-wins case, same as the Edit buffer (05-card-window.md's dirty-buffer rule; on git boards the overwritten version is a commit, one revert away). Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. **The settle also clears each open card window's fine undo stack** (ruled 2026-07-31): pre-switch steps describe the branch being left — Save All and Discard alike end with every window's stack empty, the board-stack discard-and-reseed precedent one level down; the windows stay open, following their cards onto the new branch with fresh stacks. **The flush is Save-All-shaped** (blessed 2026-07-31): after Discard, the session's reverted bytes are *reconciled, never flushed* — the pending window for that folder drops, since disk again agrees with HEAD and committing the discarded saves would betray the button; pending changes elsewhere on the board still flush normally. (Inline title editors need no step of their own: reaching the popover's picker or the sheet's create control commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.)
- **Settle the editors first — explicitly, never silently.** Branch switch neither silently commits nor silently abandons an open card-body Edit session: if any open card window has one (unsaved keystrokes, or on-disk ~700 ms saves the session hasn't committed — the mid-session state Auto-commit above deliberately leaves uncommitted), the switch presents a **save-or-discard step**: **Save All** ends every session with its normal commit (each card's Edit→Preview flip), **Discard** reverts buffers and uncommitted saves to HEAD, **Cancel** keeps the current branch and the sessions. **Open raw-source buffers are settled by the same step** (settled — an unsettled raw buffer is the worse hazard: its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new branch's card): Save All *applies* each raw buffer — and since Apply validates, a buffer that fails validation cancels the whole switch with focus on the offending window, nothing half-switched; Discard exits raw source without writing; Cancel keeps everything. External checkouts the app can't gate are the accepted last-writer-wins case, same as the Edit buffer (05-card-window.md's dirty-buffer rule; on git boards the overwritten version is a commit, one revert away). Silently flushing the commit alone would be wrong twice over: the tree can be clean precisely because a save hasn't landed, and a later debounced save would write old-branch text onto the new branch's card. With sessions settled, the pending auto-commit flushes (flush-before-overwrite above) and checkout runs on a truly settled tree: it cannot fail dirty, and no in-flight work is lost or dragged across branches. **The settle also clears each open card window's fine undo stack** (ruled 2026-07-31): pre-switch steps describe the branch being left — Save All and Discard alike end with every window's stack empty, the board-stack discard-and-reseed precedent one level down; the windows stay open, following their cards onto the new branch with fresh stacks. **The flush is Save-All-shaped** (blessed 2026-07-31): after Discard, the session's reverted bytes are *reconciled, never flushed* — the pending window for that folder drops, since disk again agrees with HEAD and committing the discarded saves would betray the button; pending changes elsewhere on the board still flush normally. (Inline title editors need no step of their own: reaching the popover's picker or its New Branch… field commits them — click-away commits, and board-scoped commands disable while one is focused — 04-interactions.md ▸ Grammar.)
- **The undo/redo stack does not survive a switch.** It is discarded and reseeded from the new HEAD's first-parent ancestry — the relaunch rule applied at switch time; redo starts empty. (Replaying a restore commit from the previous branch onto the new one would be wrong.)
- **Everything remote-facing tracks the current branch**: ahead/behind, Pull/Push, and push-on-commit all operate against the current branch's upstream. On a branch with no upstream yet, the first push — manual or push-on-commit — **creates it on the remote quietly** (`push -u` semantics): creating a remote branch is non-destructive, and quiet is consistent with push-failures-never-nag (07-sync-collab.md). Genuine failures queue with the badge as usual.
- The switch itself is bracketed (02-architecture.md): watcher suspended, one full reload at the end. If that final reload fails, the board locks read-only until a successful reload — see 02's live-reload resilience; the on-screen snapshot is from the previous branch and must not be edited over the new one.
@@ -59,13 +59,13 @@ Agent and hand edits arrive through the watcher like any change and get auto-com
**Commit attribution is structural, not just a message convention.** The Writer/echo machinery (the **EchoLedger** — 02-architecture.md ▸ Components, where its matching rule and race cases are settled) lets the auto-committer classify every observed change, per file, as **app-mediated** (the user acting through the app) or **foreign** (anything else). User-driven commits carry the user's git identity; foreign changes are committed under the pinned synthetic author **`Lanework External <[email protected]>`** — so any git client can filter, log, and blame by origin. **The committer field is always the user's identity** (blessed 2026-07-31 — git's own `am`/cherry-pick convention: author = whose change, committer = who recorded it): every commit the app makes, foreign-authored included, records the user's app as its committer. The strings are API (users script against them; the `.invalid` TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness of a schema change.
**Where the user's git identity comes from** (no git install is assumed, and the sandbox doesn't read `~/.gitconfig` — honest limits, not bugs): **repo-local `.git/config` wins when present** — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The board settings sheet's identity section (03-board-ui.md — the 2026-07-31 split moved the fields out of the popover) exposes name/email fields that **write that repo-local config** — the setting *is* the file, portable to any git client, per-board by nature (work and personal boards can differ). **The fields re-read the config at 2 s while the sheet is visible** (blessed 2026-07-31): the watcher never delivers `.git`, so no board event can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused field keeps its keystrokes; dismissing the sheet stops the poll). **Writes append, reads take the last** (blessed 2026-07-31): the writer appends a plain `[user]` section and never edits existing sections or `[user "…"]` subsections (their semantics are tool-specific); the reader — like git itself — takes the last plain-section value, which is exactly what an append produces. The asymmetry lets the write always win without the writer ever reformatting what it didn't create — the frontmatter engine's never-reformat instinct applied to git config; the worst case is a slightly redundant file git reads correctly. A write whose keys already read back at their target values is skipped whole, so revisiting the sheet never grows the file. **Clearing a key is the one sanctioned in-place edit** (ruled 2026-08-06): an empty field means "no repo-local opinion", and the config format spells absence one way only — the key not being there. The append-shaped alternative, an empty `email =` line, is an opinion in the wrong direction: a repo-level empty value *overrides* the user's global `~/.gitconfig` in their own terminal and fails their commits with git's empty-ident error. So a clear deletes every plain-section line for that key — deleting fewer than all of them changes nothing under last-wins — and drops any plain `[user]` header left with no keys under it; clearing both fields leaves the file with no plain-section identity at all, the state a never-configured repo is in. Subsections stay untouchable in both directions. Absent repo config, the **derived default** applies: the macOS account's full name plus `shortname@hostname` — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the sheet's identity fields are the fix when that matters. **The derived default is passed as an explicit per-commit signature, never written into repo config** (ruled 2026-07-31 — the signature-capable commit path gates the pro-m1 ship): repo config is the record of the user's popover edits and of adopted repos' own state, and an app-written identity there would outrank the user's global `~/.gitconfig` for their *own terminal commits* in that board. The build-time interim that materializes identity into a fresh repo's `.git/config` (SwiftGitX 0.4.0's signatureless commit + the sandbox's unreadable global config) is tolerated in-tree during pro-m1 construction and must die before release — the attribution rules above (per-commit author variation) require explicit signatures anyway. A debounce window containing both kinds is **split into two commits**, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical *unless the writer says otherwise via `modified-by` (below)*. Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.
**Where the user's git identity comes from** (no git install is assumed, and the sandbox doesn't read `~/.gitconfig` — honest limits, not bugs): **repo-local `.git/config` wins when present** — standard git semantics, readable in-sandbox because it lives under the board root, and the natural state of adopted/cloned boards. The **popover's Git tab** carries an identity section (03-board-ui.md — the 2026-07-31 split moved the fields onto a settings sheet and the 2026-08-07 reversal brought them back) exposing name/email fields that **write that repo-local config** — the setting *is* the file, portable to any git client, per-board by nature (work and personal boards can differ). **The fields re-read the config at 2 s while they are visible** (blessed 2026-07-31; container amended 2026-08-07 — the poll rides with the fields, so it now lives and dies with the popover's Git tab rather than with the retired sheet): the watcher never delivers `.git`, so no board event can carry a terminal-side config edit — the unfocused-resync courtesy needs its own signal, and a visibility-scoped poll is the 15 s paused-state re-read's shape at form cadence (a focused field keeps its keystrokes; dismissing the surface stops the poll). **Writes append, reads take the last** (blessed 2026-07-31): the writer appends a plain `[user]` section and never edits existing sections or `[user "…"]` subsections (their semantics are tool-specific); the reader — like git itself — takes the last plain-section value, which is exactly what an append produces. The asymmetry lets the write always win without the writer ever reformatting what it didn't create — the frontmatter engine's never-reformat instinct applied to git config; the worst case is a slightly redundant file git reads correctly. A write whose keys already read back at their target values is skipped whole, so revisiting the fields never grows the file. **Clearing a key is the one sanctioned in-place edit** (ruled 2026-08-06): an empty field means "no repo-local opinion", and the config format spells absence one way only — the key not being there. The append-shaped alternative, an empty `email =` line, is an opinion in the wrong direction: a repo-level empty value *overrides* the user's global `~/.gitconfig` in their own terminal and fails their commits with git's empty-ident error. So a clear deletes every plain-section line for that key — deleting fewer than all of them changes nothing under last-wins — and drops any plain `[user]` header left with no keys under it; clearing both fields leaves the file with no plain-section identity at all, the state a never-configured repo is in. Subsections stay untouchable in both directions. Absent repo config, the **derived default** applies: the macOS account's full name plus `shortname@hostname` — git's own no-config fallback shape, zero ceremony. Commits pushed to a forge under the derived email won't link to a forge account; the identity fields are the fix when that matters. **The derived default is passed as an explicit per-commit signature, never written into repo config** (ruled 2026-07-31 — the signature-capable commit path gates the pro-m1 ship): repo config is the record of the user's popover edits and of adopted repos' own state, and an app-written identity there would outrank the user's global `~/.gitconfig` for their *own terminal commits* in that board. The build-time interim that materializes identity into a fresh repo's `.git/config` (SwiftGitX 0.4.0's signatureless commit + the sandbox's unreadable global config) is tolerated in-tree during pro-m1 construction and must die before release — the attribution rules above (per-commit author variation) require explicit signatures anyway. A debounce window containing both kinds is **split into two commits**, never mixed (flush-before-overwrite already orders them: foreign first, then the user's overwrite). Honest limit: the app distinguishes app-mediated from foreign, not human from agent — a hand edit in a text editor and an agent write look identical *unless the writer says otherwise via `modified-by` (below)*. Agents wanting precise attribution are encouraged (via the agent guide, 08-agent-integration.md) to commit their own changes; the app follows along.
**`modified-by` refines foreign attribution** (the self-reported provenance key — 01-storage-format.md): when every file changed in a foreign debounce window carries the same `modified-by: X`, that commit is authored as **X** with the synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part slugified; the domain marks self-reported identity, distinct from both the user and the generic external author). Any disagreement between stamps, any unstamped changed file, or any true deletion in the window falls back to `Lanework External` — a deletion leaves no file to stamp. **A folder move is not a deletion**: items match by id across the whole board (Commit messages above — the same matching that reads a move as a move, not delete+add), so a moved card attributes by its stamp like any changed file. But a bare `mv` rewrites nothing — the moved `index.md` still carries whatever the app last wrote (no stamp) and demotes the window under the unstamped-file rule — so the agent guide teaches re-stamping on move (08-agent-integration.md). Same trust level as self-committing — it's what the writer claims, accepted as such; the stale-stamp hand-edit case (01) is the known misattribution edge. Self-committing remains the precise path; the stamp is the lightweight middle.
**Two writers, one repository — the designed situation, not an edge case.** Self-committing agents mean the auto-committer shares the repo with concurrent `git` processes, and it must be graceful about it:
- **`index.lock` contention is never an error.** If the auto-committer finds the index locked (an agent's commit in flight), it backs off briefly and retries; if the lock persists, it simply re-debounces — the pending changes are still pending, and the next quiet moment commits them. No banner, no log-worthy failure: a held lock is another writer doing its job. (Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried on the next debounce.) **The same posture covers every app-initiated operation** (settled): pull, push, branch switch, and undo restore meeting a held lock wait and retry briefly, silently; contention outlasting the brief retry surfaces as a *waiting* state in the operation's in-progress banner row ("waiting for another writer's git lock"), retrying on its cadence — never an error dialog, never a hammer — and a wait that persists implausibly long names the lock path (a crashed writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's own leftovers, Abnormal repo states above). **The wait is bounded — 30 s, then a clean failure** (blessed 2026-07-31; injectable): a genuine writer finishes in seconds, so only a stale lock ever reaches the bound, and an eternal spinner holding the wholesale bracket — and with it the board — is worse than a failure that names the path to delete. The bound expiring is the clean-failure case below, tree untouched. An operation that fails *cleanly* — disk error, refused checkout; network and auth are 07-sync-collab.md's pause-and-badge story — surfaces as a one-shot banner failure naming the operation and the error, the tree left as it was; failure after the tree changed wholesale is instead 02-architecture.md's failed-final-reload lock. **Form-anchored operations answer at the form first** (ruled 2026-07-31; container updated by the popover/sheet split these forms live in the settings sheet): add-git — and later sheet-asked operations like verify-remote — fail into an inline caption in the sheet's relevant section while the sheet is up (the user asked from a form still under their eye; dismissing the sheet dismisses the stale error, retry is right there, VoiceOver reads it from the focused surface); if the sheet has been dismissed before the answer arrives, the failure falls back to the one-shot banner above — inline is the primary surface, never a silence trap. The banner enumeration stays the posture for board-wholesale brackets that outlive any one surface — branch switch and undo restore included (blessed 2026-07-31: a switch's bounded lock wait can expire long after the popover dismissed). **The two rules compose rather than conflict** (ruled 2026-08-06, the create-and-switch overlap): a board-wholesale operation asked from a form is both at once, and the form rule wins while the asking surface stands — create-and-switch asked from the sheet's Branch section answers inline there, a switch asked from the picker answers at the popover's caption, and the two are never up together (opening the sheet dismisses the popover). The banner remains the posture for whatever outlives the asking surface — the form rule's own fallback generalized: **inline while the asking surface is up, banner once it is gone.** Banner-only-everywhere was weighed and set aside — it would answer a form's question away from the form still under the user's eye. Which window's strip carries the row is 02-architecture.md's hosted-by-the-window-of-origin rule — the card window hosts its own strip, and a ⌘Z pressed there surfaces its failure there (re-homing to the board window if the card window closes first, per 02).
- **`index.lock` contention is never an error.** If the auto-committer finds the index locked (an agent's commit in flight), it backs off briefly and retries; if the lock persists, it simply re-debounces — the pending changes are still pending, and the next quiet moment commits them. No banner, no log-worthy failure: a held lock is another writer doing its job. (Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried on the next debounce.) **The same posture covers every app-initiated operation** (settled): pull, push, branch switch, and undo restore meeting a held lock wait and retry briefly, silently; contention outlasting the brief retry surfaces as a *waiting* state in the operation's in-progress banner row ("waiting for another writer's git lock"), retrying on its cadence — never an error dialog, never a hammer — and a wait that persists implausibly long names the lock path (a crashed writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's own leftovers, Abnormal repo states above). **The wait is bounded — 30 s, then a clean failure** (blessed 2026-07-31; injectable): a genuine writer finishes in seconds, so only a stale lock ever reaches the bound, and an eternal spinner holding the wholesale bracket — and with it the board — is worse than a failure that names the path to delete. The bound expiring is the clean-failure case below, tree untouched. An operation that fails *cleanly* — disk error, refused checkout; network and auth are 07-sync-collab.md's pause-and-badge story — surfaces as a one-shot banner failure naming the operation and the error, the tree left as it was; failure after the tree changed wholesale is instead 02-architecture.md's failed-final-reload lock. **Form-anchored operations answer at the form first** (ruled 2026-07-31; container updated twice — by the popover/sheet split, then back by the 2026-08-07 reversal, so these forms live in the popover's Git tab): add-git — and later form-asked operations like verify-remote — fail into an inline caption in the tab's relevant posture while the popover is up (the user asked from a form still under their eye; dismissing the popover dismisses the stale error, retry is right there, VoiceOver reads it from the focused surface); if the popover has been dismissed before the answer arrives, the failure falls back to the one-shot banner above — inline is the primary surface, never a silence trap. The banner enumeration stays the posture for board-wholesale brackets that outlive any one surface — branch switch and undo restore included (blessed 2026-07-31: a switch's bounded lock wait can expire long after the popover dismissed). **The two rules compose rather than conflict** (ruled 2026-08-06, the create-and-switch overlap; simplified 2026-08-07 by the reversal): a board-wholesale operation asked from a form is both at once, and the form rule wins while the asking surface stands — create-and-switch and a plain switch are now asked from the same surface and answer at the same caption, the popover's, so there is one inline slot for the branch affordance rather than two that had to be kept from showing at once. The banner remains the posture for whatever outlives the asking surface — the form rule's own fallback generalized: **inline while the asking surface is up, banner once it is gone.** Banner-only-everywhere was weighed and set aside — it would answer a form's question away from the form still under the user's eye. Which window's strip carries the row is 02-architecture.md's hosted-by-the-window-of-origin rule — the card window hosts its own strip, and a ⌘Z pressed there surfaces its failure there (re-homing to the board window if the card window closes first, per 02).
- **A clean tree is the happy path, not a malfunction.** When the debounce fires and the tree has nothing to commit — the agent already committed its own work — the auto-committer no-ops silently. The agent's commit, under the agent's own authorship, *is* the record; that is precisely what the self-commit recommendation is for.
- **An agent's `git add -A` can sweep up the user's not-yet-committed app-mediated changes** under the agent's authorship, muddying structural attribution for that window. Accepted limit — the app cannot police another process's staging; the agent guide (08-agent-integration.md) tells agents to commit only their own paths, which keeps well-behaved agents honest.
+2 -2
View File
@@ -38,7 +38,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
- **Welcome window**: recents rows are elements labeled "⟨name⟩, ⟨location⟩, N lanes, M cards" (name, icon, and counts all registry-cached — 02-architecture.md; the row never reads a board's files); row actions (Open / Reveal in Finder / Forget) via context menu; unavailable rows say so ("unavailable — board not found").
- **Template chooser**: templates are elements labeled by title; the mini per-lane previews are decorative and hidden from the tree.
- **Board popover**: labeled controls throughout; the ahead/behind indicator's information — counts, queued pushes, last error — must be readable as text, never conveyed by color or shape alone.
- **Board settings sheet** (03 — the 2026-07-31 popover/sheet split): titled and sectioned with headers VoiceOver can navigate by; labeled controls throughout; inline probe/verify outcomes announced from the focused section; every control Tab-reachable under Full Keyboard Access — the sheet exists partly *because* Tab-walking two dozen controls in an untitled popover failed this bar.
- **Board settings sheet** *retired 2026-08-07* (03 ▸ Board settings sheet; the 2026-07-31 popover/sheet split reversed). Its bar carries over to the popover's Git tab, which now hosts what it held: headers VoiceOver can navigate by (the Commit Identity block keeps its heading trait); labeled controls throughout; inline probe/verify outcomes announced from the focused surface; every control Tab-reachable under Full Keyboard Access. The original objection stands as the standing test — Tab-walking two dozen controls in an *untitled, unsectioned* surface fails this bar — and the tab strip plus per-block headings are what answer it now that the controls are back in the popover.
- **Style editor** (card sidebar section, board popover, Style… popover — 03-board-ui.md ▸ Styling ▸ Controls): grids are arrow-navigable, every well Tab-reachable and labeled by name (palette color, symbol name; leading wells "None" / "Default"); the current value is stated by trait, and a batch selection's mixed state reads as "mixed", never conveyed by highlight alone.
## Text scaling & visual accommodations
@@ -52,7 +52,7 @@ The stance is committed in 00-vision.md: **accessibility is a requirement of "na
## Verification
- **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`) runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome, template chooser, board popover, board settings sheet. **Pro surfaces audit through a fixture-gated tier override** (ruled 2026-08-06): the audit's every-surface claim reaches tier-gated UI (the settings sheet's Pro sections, remote/credentials, the card History section in its present state) via a launch flag honored **only when `UITestLaunch.isFixtureLaunch` is also set** — the fixture flag already redirects registry and app state to a scratch container and builds a throwaway board, so the override grants Pro on a disposable sandbox and never over real boards; a bare tier flag in the shipping binary would be a subscription bypass, and this one is not (a Terminal user gains a Pro-looking toy, not a working subscription). The weighed alternatives lose on the audit's own terms: a permanent manual-checklist carve-out would asterisk the every-surface claim, and a debug-build-only override would leave release builds unauditable. Surfaces whose reachable-state audit is genuinely partial pin what the free fixture can reach (the disabled settings row, the absent History section) *and* audit the full surface under the override — both states are shipping states, both audit.
- **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`) runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome, template chooser, board popover. *(Amended 2026-08-07: the board settings sheet retired and its controls rehomed into the popover's Git tab, so the inventory is one surface shorter; auditing that tab specifically — the popover opens on Info — is an open card, tracked with the manual pass in `KanbanUITests/AccessibilityVerification.md`.)* **Pro surfaces audit through a fixture-gated tier override** (ruled 2026-08-06): the audit's every-surface claim reaches tier-gated UI (the settings sheet's Pro sections, remote/credentials, the card History section in its present state) via a launch flag honored **only when `UITestLaunch.isFixtureLaunch` is also set** — the fixture flag already redirects registry and app state to a scratch container and builds a throwaway board, so the override grants Pro on a disposable sandbox and never over real boards; a bare tier flag in the shipping binary would be a subscription bypass, and this one is not (a Terminal user gains a Pro-looking toy, not a working subscription). The weighed alternatives lose on the audit's own terms: a permanent manual-checklist carve-out would asterisk the every-surface claim, and a debug-build-only override would leave release builds unauditable. Surfaces whose reachable-state audit is genuinely partial pin what the free fixture can reach (the disabled settings row, the absent History section) *and* audit the full surface under the override — both states are shipping states, both audit.
- **A manual VoiceOver smoke script** lives with the test plan: create lane → create card → rename → cut/paste to another lane → external edit lands (announcement heard) → delete → restore from the trash (⌘X/⌘V) → Empty Trash. Run per release; it is the canonical "does the board actually work blind" check.
## Changes from Kanban
+3 -4
View File
@@ -10,7 +10,7 @@ The single source of truth for **every command and action the app can perform**
| **M** | Menu command, effectively fixed | Undo/Redo only: NSUndoManager rewrites their titles dynamically, which defeats title-matched remapping (04). |
| **G** | Fixed grammar key | Platform grammar, deliberately not remappable — Finder's own Return/arrows aren't either (04 ▸ Grammar). |
| **P** | Pointer grammar | Clicks, drags, modifiers — not customizable. |
| **C** | Configuration control | Form-like controls (board popover, board settings sheet, chooser, welcome); the keyboard path is reachability (Board Info ⌘I / Board ▸ Board Settings… + Tab-reachable controls), not bindings — 04's configuration carve-out, containers per the 2026-07-31 popover/sheet split. |
| **C** | Configuration control | Form-like controls (board popover, chooser, welcome); the keyboard path is reachability (Board Info ⌘I + Tab-reachable controls), not bindings — 04's configuration carve-out. *(Container amended 2026-08-07: the board settings sheet and its Board ▸ Board Settings… path retired with the reversal of the 2026-07-31 popover/sheet split — 03 ▸ Board settings sheet.)* |
**Toolbar presence is a separate axis**, orthogonal to the classes: toolbar items mirror menu commands, their presence per window is user-customizable (Customize Toolbar — 03-board-ui.md ▸ Toolbar; defaults and catalogs live there), and a toolbar is never a function's only home. Labels match menu titles except Undo/Redo, whose toolbar labels stay static (03).
@@ -45,7 +45,6 @@ The single source of truth for **every command and action the app can perform**
| Board | Move Left / Move Right | ⌘← / ⌘→ | Lane selection only (one slot; never into the trash) — cards cross lanes by drag or Cut/Paste, not ⌘-arrows; disabled while any text control is focused (04 ▸ Grammar, caret-chords rule) |
| Board | Increase Lane Width / Decrease Lane Width (the stepper's re-divide semantics, never the window's size — 03 ▸ Lane) | ⌥⌘→ / ⌥⌘← | Selected lane(s) — batches over a multi-lane selection like style (03 ▸ Lane); disabled while any text control is focused (04 ▸ Grammar, caret-chords rule) |
| Board | Pull / Push | — (no default) | Remote-backed boards only (07); disabled during 06's abnormal-state pause (the whole git surface holds) and on an unresolvable remote (07's one-time remote picker case); popover twins exist |
| Board | Board Settings… | — (no default) | Opens the board settings sheet (03 — the 2026-07-31 popover/sheet split); popover row twins it |
| View | Show Trash (checkmark toggle) | — (no default) | Board window — ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled (settled; see Standard macOS furniture), so the chord is the system's; assign one via the remapping mechanism if wanted (04 ▸ Configurable bindings) |
| View | Zoom In | ⌘+ | Board window; steps the board's zoom one rung up the ladder (03 ▸ Layout — zoom). Disabled at the top rung, and while a drag session is in flight (a drag freezes geometry the level feeds — the dragged run's heights and the resting-layout cache) |
| View | Zoom Out | ⌘− | Board window; the twin, one rung down. Disabled at the bottom rung and under the same guard |
@@ -107,8 +106,8 @@ Context menus are the per-item action inventory VoiceOver reads (10 ▸ The boar
## Configuration controls (C)
- **Board popover** (Board Info ⌘I — 03 ▸ Board popover; light surface per the 2026-07-31 popover/sheet split): board rename; embedded style editor; posture lines (repo-nested explanation, unreadable/paused states — 06); branch display and switch picker; ahead/behind with Pull/Push buttons and the status badges; the Board Settings… row.
- **Board settings sheet** (Board ▸ Board Settings…, or the popover row — 03 ▸ Board settings sheet): add-git (mode none, 06); branch create; commit-identity name/email (06); add/change remote with inline verify, credential fields, the SSH key surface machine key Copy + Verify, key import by paste or drag, the per-host key picker, removal of an unreferenced import, confirm-gated machine-key regeneration the Authentication-needed capture and TOFU confirms (07); the push-on-commit toggle.
- **Board popover** (Board Info ⌘I — 03 ▸ Board popover; **the board's one configuration surface** since the 2026-08-07 reversal of the 2026-07-31 popover/sheet split): board rename and the board glyph; the Theme tab's preset backgrounds; the Info tab's dossier; and the Git tab — posture lines (repo-nested explanation, unreadable/paused states — 06); branch display and switch picker with its New Branch… reveal; add-git (mode none, 06); commit-identity name/email (06); ahead/behind with Pull/Push buttons and the status badges.
- **Board settings sheet** *retired 2026-08-07* (03 ▸ Board settings sheet). Its contents rehomed into the popover above, and Board ▸ Board Settings… left this document's menu inventory with it. 07-sync-collab.md's own surfaces — add/change remote with inline verify, credential fields, the SSH key surface (machine key Copy + Verify, key import by paste or drag, the per-host key picker, removal of an unreferenced import, confirm-gated machine-key regeneration), the Authentication-needed capture and TOFU confirms, the push-on-commit toggle — need a home ruled on that card; they were inventoried here as the sheet's and are homeless until then.
- **Style editor** (three anchors — 03 ▸ Styling ▸ Controls): grids arrow-navigable, every well Tab-reachable.
- **Template chooser** (09): template selection; Reveal in Finder for the user store.
- **Welcome** (03): recents list; Forget.
+11 -26
View File
@@ -57,12 +57,6 @@ struct BoardWindowHost: View {
/// menu item through the focus system, like the store.
@State private var boardInfo = BoardInfoPresentation()
/// This window's board settings sheet, open or not (03-board-ui.md § Board settings sheet).
/// `@State` for `boardInfo`'s reason one per window and published the same way, because
/// Board Board Settings is a menu-bar item that has to reach the frontmost board window, and
/// because the sheet is presented *on this window* and modal to it.
@State private var boardSettings = BoardSettingsPresentation()
/// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
/// reason and reaching the menu bar the same way: File Delete (landing on a trash selection)
/// and Empty Trash are menu-bar items, and a menu item cannot present anything of its own.
@@ -229,17 +223,11 @@ struct BoardWindowHost: View {
.onChange(of: BoardBackdrop.isCustom(store.snapshot, root: store.rootURL), initial: true) { _, custom in
windowController.setExtendsContentUnderTitlebar(custom)
}
// **The board settings sheet** (03-board-ui.md Board settings sheet) presented from
// the board window's own content, which is what makes it modal to *this* board rather
// than to the app: "a board-scoped, titled, sectioned sheet on the board window".
// (**The board settings sheet was presented here** between 2026-07-31 and 2026-08-07,
// when the popover/sheet split was reversed: the sheet retired, its contents rehomed
// into the popover's Git tab, and this window has no modal configuration surface at all
// now 03-board-ui.md Board settings sheet, marked retired.)
//
// It hangs here rather than inside `BoardView` for the reason the popover's flag is a
// window's: the two doors that open it are the titlebar popover's row and a menu-bar
// item, neither of which is inside the board, and a sheet has to be presented by
// something that outlives the surface that asked for it.
.sheet(isPresented: $boardSettings.isPresented) {
BoardSettingsSheet(store: store, presentation: boardSettings)
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
// beside it the window's own popover flag, which is what File Board Info toggles, its
// purge-alert host, which the trash's two confirmed commands raise, and its search
@@ -250,7 +238,6 @@ struct BoardWindowHost: View {
// is keyed on the window rather than on the board it is showing.
.focusedSceneValue(\.boardWindowRef, ref)
.focusedSceneValue(\.boardInfo, boardInfo)
.focusedSceneValue(\.boardSettings, boardSettings)
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
// Board Open Card's second half the same closure `BoardView` gets, so the menu item
// and the double-click open one window per card by construction.
@@ -742,21 +729,19 @@ struct BoardWindowHost: View {
// **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.
//
// The settings sheet used to adopt the same fact here, so its two doors could validate on it
// (2026-07-31 2026-08-07). The sheet is retired and the popover is the one configuration
// home, so the git state is handed to exactly one surface again this widget and the
// mid-session transition 06 sanctions (add-git flipping the mode) re-resolves the Git tab's
// postures under it live, with no second reader to disagree.
let session = appModel.session(for: ref)
// 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(git: session?.git)
windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory(
store: store,
recents: appModel.styleRecents,
git: session?.git,
presentation: boardInfo,
settings: boardSettings
presentation: boardInfo
)
)
// The widget above now says the board's name (and, on a git-mode board, its branch)
+2 -2
View File
@@ -24,7 +24,7 @@ import Foundation
/// answers `EACCES`/`EPERM` to is not "no repo there" it is "cannot tell" and folding that into
/// `.none` would let add-git offer app-managed init on a board that might already sit inside a
/// repository the app simply could not see. `unverifiable` therefore takes `repoNested`'s posture
/// everywhere structural (no add-git, no app-managed git, `BoardSettingsSheet.resolve` empty), since
/// everywhere structural (no add-git, no app-managed git, `BoardGitSetupSection.resolve` empty), since
/// the two share the one property every surface but the popover's prose cares about: neither may be
/// added to. Its prose is its own "unverifiable" is not "nested", and telling a user their board
/// sits inside a repository when the honest answer is "couldn't check" would be a lie dressed as
@@ -63,7 +63,7 @@ public enum BoardGitMode: String, Sendable, Equatable, CaseIterable {
/// was **denied** (`EACCES`/`EPERM`) rather than answered the sandbox refusing to say whether
/// an ancestor above its grant carries a repository (06 Rules Detection, "Denial is not
/// absence", ruled 2026-07-31). Structurally this takes `repoNested`'s posture: no add-git, no
/// app-managed git anywhere, `BoardSettingsSheet.resolve` empty a denial can never be told
/// app-managed git anywhere, `BoardGitSetupSection.resolve` empty a denial can never be told
/// apart from a repository actually being there, so the conservative posture is the only honest
/// one. Its prose is its own: the popover explains that Lanework could not verify whether the
/// board sits inside a repository, never the `repoNested` sentence verbatim denial is not
+2 -1
View File
@@ -92,7 +92,8 @@ public final class GitBranchSwitcher {
///
/// The banner rather than an inline caption, deliberately, and 06 draws the line: the
/// form-anchored answer is for operations that answer *at the form* (add-git, verify-remote
/// forms that live in the board settings sheet since the 2026-07-31 popover/sheet split),
/// forms that live in the board popover's Git tab; they moved to the settings sheet with the
/// 2026-07-31 popover/sheet split and came back with the 2026-08-07 reversal),
/// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
/// one surface" which a branch switch is by construction, since its bracket locks the board and
/// its completion is announced.
+8 -6
View File
@@ -10,9 +10,10 @@ import Foundation
/// 1. **Repo-local `.git/config` wins when present.** "Standard git semantics, readable in-sandbox
/// because it lives under the board root, and the natural state of adopted/cloned boards." The
/// identity fields write exactly that file: "the setting *is* the file, portable to any git
/// client, per-board by nature". Their home is the **board settings sheet** since the 2026-07-31
/// popover/sheet split (03-board-ui.md); they are hosted in the popover's git section until that
/// sheet is built, which changes nothing about this file.
/// client, per-board by nature". Their home is the **board popover's Git tab** (03-board-ui.md):
/// the popover's git section originally, the board settings sheet between the 2026-07-31
/// popover/sheet split and the 2026-08-07 reversal that retired it, and the Git tab since none
/// of which changes anything about this file.
/// 2. **Absent repo config, the derived default**: "the macOS account's full name plus
/// `shortname@hostname` git's own no-config fallback shape, zero ceremony."
///
@@ -190,9 +191,10 @@ enum GitConfigFile {
// MARK: Writing
/// **The identity fields, landing in the file** (06-history-undo.md Interaction with external
/// writers: "The board settings sheet's identity section exposes name/email fields that **write
/// that repo-local config** the setting *is* the file, portable to any git client, per-board by
/// nature"; the fields are popover-hosted until that sheet is built).
/// writers: "the identity section exposes name/email fields that **write that repo-local
/// config** the setting *is* the file, portable to any git client, per-board by nature"; the
/// fields are popover-hosted, as they were before the 2026-07-31 split and are again since the
/// 2026-08-07 reversal).
///
/// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
/// is the design's own line: the derived default "is passed as an explicit per-commit signature,
+7 -6
View File
@@ -76,10 +76,11 @@ public final class HistoryStore {
/// dismissing the form clears it ("dismissing the sheet dismisses the stale error"). The other
/// half is `reportFailure`, which posts the banner when the answer arrives to an empty room.
///
/// The form is the **board settings sheet's Git section** (`BoardSettingsSheet`), which is where
/// the 2026-07-31 popover/sheet split put add-git; it was the popover's git section until that
/// sheet was built, and `noteFormVisible(_:)` is the one line the move re-pointed the ruling's
/// container changed, its substance did not.
/// The form is the **popover's Git tab, in its no-repository posture** (`BoardGitAddAction`),
/// which is where add-git has lived since the 2026-08-07 reversal and where it lived before
/// the 2026-07-31 popover/sheet split moved it to the sheet for the week that sheet existed.
/// `noteFormVisible(_:)` is the one line either move re-pointed: the ruling's container changed
/// twice, its substance neither time.
public private(set) var lastFailure: GitOperationFailure?
/// Whether the form add-git was asked from is on screen right now (`noteFormVisible(_:)`).
@@ -412,8 +413,8 @@ public final class HistoryStore {
/// **Refused against an unreadable repository** (06 Rules, the corrupt-`.git` loud failure:
/// "Lanework leaves the repository untouched"). This is the one identity call that *writes*, and
/// `.git/config` is the file most likely to be what is wrong with a repository libgit2 will not
/// open. Unreachable in practice the sheet that hosts these fields resolves to nothing on such a
/// board (`BoardSettingsSection.resolve`) and gated anyway, because "never touched" is a
/// open. Unreachable in practice the Git tab hosts no setup block on such a board
/// (`BoardGitSetupSection.resolve`, empty there) and gated anyway, because "never touched" is a
/// promise about the repository rather than about which surfaces happen to be reachable.
public func writeIdentity(name: String, email: String) async {
guard mode == .git, !isRepositoryUnreadable else { return }
+7 -11
View File
@@ -265,15 +265,15 @@ struct KanbanApp: App {
}
// The Board menu (11-command-nexus.md), complete and in its inventoried row order Open
// Card, Rename, Style, the card moves, the lane moves, the width pair, the remote pair, then
// Board Settings. Its items act on the frontmost board window, which they reach through the
// focus system rather than through the app model see `BoardCommands.swift`, which also owns
// Card, Rename, Style, the card moves, the lane moves, the width pair, then the remote
// pair. Its items act on the frontmost board window, which they reach through the focus
// system rather than through the app model see `BoardCommands.swift`, which also owns
// their validation.
//
// Board Settings sits below a divider rather than beside Pull/Push: the Nexus lists it last
// in this menu, and the two families differ Pull and Push are recurring *operations* on a
// remote, and the sheet is where a board is *set up* (03 Board settings sheet, the
// 2026-07-31 popover/sheet split).
// **Board Settings came out 2026-08-07** with the sheet it opened (03 Board settings
// sheet, marked retired; the 2026-07-31 popover/sheet split reversed): a board is configured
// in its popover, whose keyboard door is File Board Info I, so this menu's last divider
// went with the row rather than being left to separate the remote pair from nothing.
CommandMenu("Board") {
OpenCardCommand()
BoardRenameCommand()
@@ -291,10 +291,6 @@ struct KanbanApp: App {
Divider()
RemoteCommands()
Divider()
BoardSettingsCommand()
}
CommandGroup(after: .windowList) {
+10 -61
View File
@@ -87,17 +87,17 @@ extension BoardStore {
/// > enabled menu key equivalent fires before the field ever sees the key, and / are the
/// > standard line-start/end caret chords.
///
/// Five text surfaces, answered four ways, and only three of them are here:
/// Four text surfaces, answered three ways, and only two of them are here:
///
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule a
/// broader lockdown that already covers these two items.
/// - **The board popover's fields** are covered by disabling while the popover is open at all
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
/// move belongs under it.
/// - **The board settings sheet's fields** are the popover's rule for the popover's reason, and the
/// surface the 2026-07-31 split moved most of those fields *to* (branch name, commit identity, and
/// pro-m2's remote URL and credentials): the sheet is the other half of 04's configuration
/// carve-out, and a lane move under a modal settings surface is not a gesture that exists.
/// move belongs under it. That clause covers **every** configuration field the app has again since
/// 2026-08-07: the 2026-07-31 split moved most of them to a board settings sheet, whose flag this
/// function read as a third disjunct, and the reversal brought them back branch creation, commit
/// identity, and pro-m2's remote URL and credentials all live in the popover's Git tab, under the
/// popover's own open-at-all rule.
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
@@ -109,12 +109,9 @@ extension BoardStore {
@MainActor
func caretChordsYield(
boardInfo: BoardInfoPresentation?,
boardSettings: BoardSettingsPresentation?,
search: BoardSearchPresentation?
) -> Bool {
boardInfo?.isPresented == true
|| boardSettings?.isPresented == true
|| search?.isFocused == true
boardInfo?.isPresented == true || search?.isFocused == true
}
// MARK: - Open Card
@@ -286,13 +283,12 @@ struct MoveCardCommands: View {
/// **Caret chords yield to any focused text control** (04-interactions.md Grammar, settled):
/// / are the standard line-start/end chords, and an enabled key equivalent fires before a
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
/// `caretChordsYield(boardInfo:boardSettings:search:)`'s doc comment shared verbatim with the
/// `caretChordsYield(boardInfo:search:)`'s doc comment shared verbatim with the
/// width pair below.
struct MoveLaneCommands: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var boardInfo
@FocusedValue(\.boardSettings) private var boardSettings
@FocusedValue(\.boardSearch) private var search
var body: some View {
@@ -310,7 +306,7 @@ struct MoveLaneCommands: View {
}
private var yieldsCaretChords: Bool {
caretChordsYield(boardInfo: boardInfo, boardSettings: boardSettings, search: search)
caretChordsYield(boardInfo: boardInfo, search: search)
}
/// The sole selected live lane and the display slot one step would put it in `nil` when there
@@ -427,52 +423,6 @@ struct BoardInfoCommand: View {
}
}
// MARK: - Board Settings
/// Board Board Settings no default chord (11-command-nexus.md: " (no default)"), and the board
/// settings sheet's second door, the popover's row being the first (03-board-ui.md Board settings
/// sheet).
///
/// **"Remappable" asks nothing of this file.** The remapping mechanism is macOS's own System
/// Settings Keyboard App Shortcuts, keyed on the menu item's *title* (04-interactions.md
/// Configurable bindings) so all a chordless row owes it is a stable title, which the Nexus fixes.
/// A `Button` with no `keyboardShortcut` is therefore the whole implementation, exactly as File
/// Save as Template and Board Rename are.
///
/// ### It opens; it never toggles
///
/// Board Info I toggles because a popover reached by a chord would otherwise have no keyboard way
/// out. A sheet has one built in (Done, and Escape through it), and the menu is behind the sheet
/// while it is up so a toggling row would be a second exit nobody can reach.
///
/// ### Validation: scope, then reachability and never the lock
///
/// The row stays **visible and disabled** where the sheet cannot exist (`BoardSettingsAvailability`,
/// 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
/// its git setup, and the controls inside disable themselves in place (03 Board settings sheet).
struct BoardSettingsCommand: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardSettings) private var presentation
var body: some View {
Button("Board Settings…") {
presentation?.present()
}
.disabled(store == nil || presentation?.isReachable != true)
}
}
// MARK: - Rename
/// Board Rename no default chord, deliberately (11-command-nexus.md: " (cards: Return in
@@ -549,7 +499,6 @@ struct LaneWidthCommands: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var boardInfo
@FocusedValue(\.boardSettings) private var boardSettings
@FocusedValue(\.boardSearch) private var search
var body: some View {
@@ -567,7 +516,7 @@ struct LaneWidthCommands: View {
}
private var yieldsCaretChords: Bool {
caretChordsYield(boardInfo: boardInfo, boardSettings: boardSettings, search: search)
caretChordsYield(boardInfo: boardInfo, search: search)
}
/// The selected live lanes, in snapshot order the batch, and the items' validation.
+124 -18
View File
@@ -35,9 +35,11 @@ struct BoardGitBranchSurface: Equatable {
/// its own sentence instead (`unreadableNote`), and the branch line has no answer to wait for.
let isRepositoryUnreadable: Bool
/// Whether the branch controls accept a click the popover's switch picker, and the settings
/// sheet's create field (`BoardSettingsSheet`), which resolves this same surface so that a
/// paused repository, a read-only board and a switch in flight close both by one rule.
/// Whether the branch controls accept a click the switch picker, and the New Branch reveal
/// behind its divider (`BoardGitControls`), so that a paused repository, a read-only board and a
/// switch in flight close the whole branch affordance by one rule. (The settings sheet's standing
/// Create field resolved this same surface between 2026-07-31 and the 2026-08-07 reversal; the
/// field came back into the menu, the rule never moved.)
let controlsEnabled: Bool
/// The line the branch display is read as by VoiceOver.
@@ -108,10 +110,16 @@ struct BoardGitBranchSurface: Equatable {
/// **The popover's git section on a board that has a repository** (03-board-ui.md Board popover;
/// 06-history-undo.md Branch switching).
///
/// **The daily face, and only that** (the 2026-07-31 popover/sheet split): the branch display with
/// its **switch** picker, and the pause explanation when the surface is held. Branch *creation* and
/// the commit-identity fields left with the split they are setup, and setup's home is the board
/// settings sheet (`BoardSettingsSheet`), which the section's Board Settings row opens.
/// **The whole branch affordance**: the branch display with its **switch** picker, the *New Branch*
/// entry behind that menu's divider and the inline field it reveals, and the pause explanation when
/// the surface is held.
///
/// **Creation left and came back.** The 2026-07-31 popover/sheet split moved it to the board settings
/// sheet as a standing Create field; the **2026-08-07 reversal** retired that sheet and restored the
/// shape the sheet's own doc comment had described as "the right shape *there*" an entry inside the
/// switch menu that reveals an inline field. The commit-identity fields came back the same day, to
/// the Git tab a level up (`BoardGitSetup.swift`), which is why they are not here: they are the
/// board's setup, and this is the branch.
///
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push and the status badges are
/// 07-sync-collab.md's own cards, and this section is arranged so they join as one more block under
@@ -125,6 +133,20 @@ struct BoardGitControls: View {
/// refuses a checkout most of all it rewrites the tree the lock exists to stop describing.
let isEnabled: Bool
/// Whether **New Branch** has been picked and its field is standing open the reveal, which is
/// the menu entry's whole behaviour (03-board-ui.md Board popover Git tab, 2026-08-07). It
/// starts closed on every popover open, because the popover is rebuilt fresh each time
/// (`BoardInfoWidget`), which is exactly the transience the reveal shape is for.
@State private var isCreatingBranch = false
/// The name being typed, uncommitted. Cleared by a create and by Escape's first press.
@State private var draft = ""
/// Focus lands in the field the moment it appears, unlike the popover's rename field: this one is
/// opened by a gesture that means "name a branch now", which is the inline editors' case rather
/// than the configuration-surface case (11-command-nexus.md's class **C** distinguishes the two).
@FocusState private var isFieldFocused: Bool
private var surface: BoardGitBranchSurface {
BoardGitBranchSurface.resolve(
branch: git.branch,
@@ -138,6 +160,10 @@ struct BoardGitControls: View {
VStack(alignment: .leading, spacing: 8) {
branchRow
if isCreatingBranch {
creationField
}
// **The unreadable repository names itself in its own sentence** (06 Rules, the
// corrupt-`.git` loud failure) checked before the pause note because it *is* a pause,
// and the pause note's second line ("finishing it belongs to the tool that started it")
@@ -168,12 +194,17 @@ struct BoardGitControls: View {
/// makes "branch/source display and switching" one affordance rather than a label with a button
/// beside it.
///
/// **Switch entries and nothing else** since the 2026-07-31 split the "New Branch" entry that
/// used to close the menu is the settings sheet's standing Create field now. **A single-branch
/// board therefore opens onto a disabled explanatory row** (03-board-ui.md Board popover Git
/// tab, ruled 2026-08-06 and built with the tab): the earlier posture left the menu genuinely
/// empty and called that honest, but honest is not the same as legible a menu that opens onto
/// nothing reads as broken, not as complete.
/// **Two halves under a divider** since the 2026-08-07 reversal: the switch targets above, and
/// **New Branch** below, which reveals the inline field under this row rather than acting. That
/// is the shape the 2026-07-31 split took creation *out* of into the settings sheet's standing
/// Create field and the shape the reversal restored when the sheet retired; the sheet's own doc
/// comment had called it "the right shape *there*", meaning here.
///
/// **The menu is therefore never empty**, which is what the divider guarantees rather than the
/// content: New Branch always applies to a board that has a repository. **A single-branch board
/// still opens onto a disabled explanatory row** in the upper half (03-board-ui.md Board popover
/// Git tab, ruled 2026-08-06 and built with the tab): an unexplained gap above the divider would
/// read as a menu that lost something, not as a board with one branch.
private var branchRow: some View {
// Resolved once and handed to both halves of the menu builder: the emptiness *is* the
// condition being rendered, so asking twice would be asking the same question of a store
@@ -188,11 +219,12 @@ struct BoardGitControls: View {
Menu {
if targets.isEmpty {
// **The single-branch board's row** (03-board-ui.md Board popover Git tab,
// ruled 2026-08-06). It teaches the two things an empty menu leaves a user to
// guess at: *why* there is nothing to pick this menu holds only the **other**
// local branches, and there are none and, by standing where "New Branch" used
// to, that creation is no longer here at all; it is the settings sheet's Create
// field, one row down through Board Settings
// ruled 2026-08-06). It teaches the one thing an empty upper half leaves a user
// to guess at: *why* there is nothing to pick this half holds only the
// **other** local branches, and there are none. (It carried a second teaching
// while creation lived in the settings sheet, standing where "New Branch" used
// to; the 2026-08-07 reversal put that entry back one line below, so the row's
// second job is done and the sentence is now exactly what it says.)
//
// A bare `Text` inside a `Menu` is AppKit's standard disabled item: greyed,
// unclickable, and read by VoiceOver as disabled text rather than as an
@@ -207,6 +239,15 @@ struct BoardGitControls: View {
}
}
}
Divider()
// **Creation, revealed rather than performed** the entry opens the field below the
// row and nothing else, so the act of naming a branch happens in the surface the user
// can see and correct rather than inside a menu that has already closed.
Button("New Branch…") {
isCreatingBranch = true
}
} label: {
Text(surface.branchLabel)
.font(.callout)
@@ -254,6 +295,71 @@ struct BoardGitControls: View {
branches.filter { $0 != current }
}
// MARK: The creation reveal
/// **What New Branch reveals** (03-board-ui.md Board popover Git tab, restored 2026-08-07):
/// a name field and a Create button, under the branch row, for exactly as long as the user is
/// naming something. It is a detour off the daily face and it looks like one which is the
/// argument the 2026-07-31 split made *against* it in a sheet, and for it here.
///
/// **The sequence is not this view's.** `GitBranchSwitcher.createAndSwitch(to:)` runs the
/// identical settle flush stamp switch sequence the picker's switch does "no at-HEAD fast
/// path" (06-history-undo.md Branch switching, blessed 2026-07-31) and `create()` calls
/// exactly that method.
///
/// **Failures answer where they were asked**: the section's existing switcher caption below is
/// this control's answer too (06 Rules, the form-anchored rule as the 2026-08-06 create-and-
/// switch overlap settled it "inline while the asking surface is up, banner once it is gone").
/// The popover *is* the asking surface now that the sheet is retired, so there is one caption for
/// both halves of the branch affordance and no second slot to keep in sync.
///
/// No `ProgressView` of its own, for the same reason: `branchRow` already shows the in-flight
/// spinner, and a switch has one state whichever control started it.
private var creationField: some View {
HStack(spacing: 6) {
TextField("New branch name", text: $draft)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($isFieldFocused)
.onAppear { isFieldFocused = true }
.onSubmit { create() }
// **Escape steps outward one layer per press** (04-interactions.md Grammar, the
// rename field's rule and the retired sheet section's before it): a dirty field
// abandons its draft and the reveal stays open; an empty one closes the reveal, which
// is the layer above it; a further press then reaches the popover's own dismissal.
.onKeyPress(.escape) {
if !draft.isEmpty {
draft = ""
return .handled
}
isCreatingBranch = false
return .handled
}
.accessibilityLabel("New branch name")
Button("Create", action: create)
.disabled(trimmedDraft.isEmpty)
}
// The branch controls' one rule, applied to the reveal as it is to the picker: a paused
// repository, a read-only board and a switch in flight close it (`BoardGitBranchSurface`).
.disabled(!surface.controlsEnabled)
}
private var trimmedDraft: String {
draft.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Creates the named branch and switches to it, then puts the reveal away a create is the end
/// of the detour, and a field left standing over a branch that now exists would be inviting a
/// second one nobody asked for.
private func create() {
let name = trimmedDraft
guard !name.isEmpty, surface.controlsEnabled else { return }
draft = ""
isCreatingBranch = false
Task { await git.switcher?.createAndSwitch(to: name) }
}
// MARK: The pause
/// **The abnormal-state surface** (06 Rules Abnormal repo states) deferred here from the
+229
View File
@@ -0,0 +1,229 @@
import SwiftUI
/// **The Git tab's two setup controls** add-git and the commit-identity fields (03-board-ui.md
/// Board popover Git tab; 06-history-undo.md Rules Opt-in init and Interaction with external
/// writers).
///
/// ### One configuration home again (ruled 2026-08-07)
///
/// These two lived in the popover, moved to the board settings sheet with the **2026-07-31
/// popover/sheet split**, and came back with the **2026-08-07 reversal** that retired that sheet: the
/// popover is the board's one configuration surface, and its Git tab is where a board's repository is
/// both operated and set up. Nothing about either control's *substance* moved in either direction
/// what moved is the container, and every rule stated below is the same rule the sheet carried,
/// re-pointed at the tab.
///
/// The one thing the reversal does change is what "the form is visible" means: it is the **popover's**
/// visibility now, and a popover is transient where a sheet was not. That is fine for both rules that
/// depend on it add-git's inline-answer window and the identity fields' poll are both scoped to "the
/// surface the user asked from is still under their eye", and a popover dismissed by a stray click
/// ends that window exactly as Done ended the sheet's (06 Rules: "inline while the asking surface is
/// up, banner once it is gone").
///
/// They live in their own file rather than in `BoardGitTabView.swift` because they are the *setup*
/// half the tab's postures and its daily branch face are that file's and `BoardGitControls.swift`'s
/// and because a pro-m2 card adding the remote and credential surfaces adds them beside these,
/// under the same rules, rather than into the posture switch.
// MARK: - Geometry
/// The setup form's one figure, **derived from the body font** like every other surface's
/// (10-accessibility.md Text scaling: "relative text styles everywhere, no fixed point sizes").
///
/// It is what survives `BoardSettingsSheetLayout`, which retired with the sheet 2026-08-07: the
/// sheet's width and section inset were a *window's* geometry and the popover supplies both itself
/// (`BoardInfoView`), but the identity form's label column is the form's own and travelled with it.
enum BoardGitSetupLayout {
/// The identity form's label column. 3.4 em: 44pt at the standard body, which is what those
/// fields have always drawn.
static func labelColumn(bodyPointSize: CGFloat) -> CGFloat {
BoardMetrics.em(3.4, bodyPointSize: bodyPointSize)
}
}
// MARK: - Add git
/// **The add-git action** (06-history-undo.md Rules Opt-in init) the one place in the app that
/// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than
/// a promise: there is no other caller of `HistoryStore.addGit`.
///
/// The caption states what pressing it does, in the order it happens, because it is not undoable in
/// the ordinary sense: a repository appears in the board's folder and its current state becomes the
/// first commit.
///
/// **It renders inline in the Git tab's no-repository posture** since the 2026-08-07 reversal under
/// the note that states the fact, where the Board Settings door stood between 2026-07-31 and that
/// day. The posture is unchanged in every other respect: the fact, then the offer.
struct BoardGitAddAction: View {
let git: HistoryStore
/// The read-only lock's reach (02-architecture.md The lock's scope): a board that refuses
/// writes refuses this one too initializing a repository is a write, and a commit is several.
/// The popover **stays open** under the lock and disables in place, which is the style popover's
/// settled precedent (03 Board popover).
let isEnabled: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Button("Add Git") {
Task { await git.addGit() }
}
.disabled(!isEnabled || git.isAddingGit)
Text("Creates a git repository in this board's folder and commits its current state.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let failure = git.lastFailure {
Text(failure.message)
.font(.caption)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
// **The form add-git answers at** (06 Interaction with external writers, ruled 2026-07-31
// "Form-anchored operations answer at the form first"): inline while this control is on
// screen, the banner once it is gone. Appearing claims the inline surface; disappearing gives
// it up, which both dismisses the stale error and sends any answer still in flight to the
// banner instead of to nobody.
//
// The control's visibility *is* the Git tab's, and the tab's is the popover's a narrower
// window than the sheet's was until 2026-08-07, and the right one: a popover dismissed by a
// click outside is precisely the user leaving the form. Two other disappearances are not
// dismissals and both are correct: switching to Info or Theme, which puts the question away
// as surely as closing the popover, and a *successful* add-git flipping the mode out from
// under this posture the failure slot empties because there is nothing left to fail.
.onAppear { git.noteFormVisible(true) }
.onDisappear { git.noteFormVisible(false) }
}
}
// MARK: - Commit identity
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md Interaction
/// with external writers: "the identity section exposes name/email fields that write that repo-local
/// config the setting *is* the file, portable to any git client, per-board by nature").
///
/// The fields moved to the sheet with the 2026-07-31 split and back to the popover's Git tab with the
/// 2026-08-07 reversal, poll included 06 says the visibility-scoped re-read "rides with the fields",
/// so hosting the view here *is* the re-point: the `.task` below now lives and dies with the tab.
///
/// ### The placeholder is the whole of the identity rule made visible
///
/// An empty field shows the **derived default** the macOS account's full name and
/// `shortname@hostname` as a placeholder, never as a value. That is the difference between "this
/// repository says nothing, so the app signs commits with a sensible guess" and "this repository says
/// this", and the file is where the difference lives: 06 forbids the app writing its own derived
/// value into config, because it would then outrank the user's global `~/.gitconfig` for their own
/// terminal commits in that board. A field pre-filled with the derived value would write it on the
/// first focus loss.
///
/// ### The dirty-buffer courtesy, copied from `BoardRenameField`
///
/// A foreign config edit landing while the tab is open updates an *unfocused* field and never a
/// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md Board popover). The
/// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher`
/// filters `.git` out of the watch by design, so no board event can ever carry a config change, and
/// the alternative to a small periodic read is a field that is stale for as long as the surface stays
/// open.
struct BoardGitIdentityFields: View {
let git: HistoryStore
let isEnabled: Bool
@State private var name = ""
@State private var email = ""
@FocusState private var focused: Field?
private enum Field: Hashable {
case name
case email
}
/// **The fields re-read the config at 2 s while they are visible** (06 Interaction with
/// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event
/// can carry a terminal-side config edit the unfocused-resync courtesy needs its own signal, and
/// a visibility-scoped poll is the 15 s paused-state re-read's shape at form cadence (a focused
/// field keeps its keystrokes; dismissing the surface stops the poll)." The surface the ruling
/// named was the sheet; since 2026-08-07 it is the popover's Git tab, which is a *shorter* life
/// than the sheet's and therefore a strictly smaller poll.
private static let pollInterval: Duration = .seconds(2)
var body: some View {
VStack(alignment: .leading, spacing: 6) {
field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name)
field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email)
if let failure = git.identityFailure {
Text(failure.message)
.font(.caption)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
.task {
// The first read, then the courtesy poll. Cancellation is the view's disappearance, which
// is the popover closing or the tab strip moving off Git.
while !Task.isCancelled {
await git.refreshIdentity()
try? await Task.sleep(for: Self.pollInterval)
}
}
.onAppear {
name = git.identityName
email = git.identityEmail
}
.onChange(of: git.identityName) { _, value in
guard focused != .name else { return }
name = value
}
.onChange(of: git.identityEmail) { _, value in
guard focused != .email else { return }
email = value
}
// A dismissal is a commit like any other click-away `BoardRenameField`'s rule, and the same
// idempotence makes the overlap harmless.
.onDisappear { commit() }
}
private func field(
_ label: String,
text: Binding<String>,
placeholder: String,
tag: Field
) -> some View {
HStack(spacing: 6) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(
width: BoardGitSetupLayout.labelColumn(bodyPointSize: BoardMetrics.bodyPointSize),
alignment: .leading
)
TextField(placeholder, text: text)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($focused, equals: tag)
.onSubmit { commit() }
.disabled(!isEnabled)
.accessibilityLabel("Commit \(label.lowercased())")
}
.onChange(of: focused) { previous, _ in
// Focus leaving *this* field is this field's commit the inline editors' exit, applied
// to a form where Tab moves between two of them.
guard previous == tag else { return }
commit()
}
}
/// Writes both fields, and only when one of them differs from what the file says an unchanged
/// value must not rewrite `.git/config` every time the popover closes.
private func commit() {
guard isEnabled else { return }
guard name != git.identityName || email != git.identityEmail else { return }
Task { await git.writeIdentity(name: name, email: email) }
}
}
+177 -63
View File
@@ -2,11 +2,21 @@ import Foundation
import SwiftUI
/// **The board popover's Git tab** (03-board-ui.md § Board popover Git tab, settled 2026-08-07)
/// the pre-tab closing git section rehomed *whole*, and nothing more: **the daily face, and only
/// that** (the 2026-07-31 popover/sheet split stands, unchanged by the move). A repository-facts
/// dossier in the Info tab's register commit counts, last-commit dates was considered in the Git
/// session and declined: the popover's git surface is for *operating*, and per-item history is the
/// card window's History section (05-card-window.md).
/// the pre-tab closing git section rehomed *whole*, and then, later the same day, **the board
/// settings sheet's contents rehomed into it too**. A repository-facts dossier in the Info tab's
/// register commit counts, last-commit dates was considered in the Git session and declined: the
/// popover's git surface is for *operating* and *setting up*, and per-item history is the card
/// window's History section (05-card-window.md).
///
/// ### Operating and setup, one surface again (ruled 2026-08-07)
///
/// The tab settled as "the daily face, and only that" the 2026-07-31 popover/sheet split's half
/// with a **Board Settings** row pointing at the sheet that held add-git, branch creation and the
/// commit identity. That split is **reversed**: the sheet retires, the row with it, and the two setup
/// controls render inline in the postures they belong to (`BoardGitSetup.swift`). Branch creation
/// went back where it came from, the switch menu (`BoardGitControls.branchRow`). What is left is one
/// configuration home per board the popover reached by the widget or Board Info I, and no
/// surface that has to be validated into existence before a door can point at it.
///
/// ### No "Git" header anywhere in this tab
///
@@ -35,12 +45,14 @@ import SwiftUI
/// 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
/// all (the case was called `.addGit` when it did a name that would now be describing a control
/// that lives in another file, so it is `.noRepository`), and the git-mode case lost branch creation
/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its
/// test survived the move unchanged.
/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either, and the
/// 2026-08-07 reversal filled them back in.** Setup left the popover for the board settings sheet,
/// so mode `none` rendered no action here at all for a week (the case was called `.addGit` when it
/// did, and was renamed `.noRepository` when the control left) and the git-mode case lost branch
/// creation and the identity fields; the reversal retired that sheet and brought all three back
/// inline. The case name stays `.noRepository` it describes the board, which is what a posture is
/// for, and it survived the round trip precisely because it never named a control. What each case
/// *is* is a posture, which is why the matrix and its test survived both moves unchanged.
///
/// **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
@@ -55,28 +67,30 @@ import SwiftUI
enum BoardGitSection: Equatable, CaseIterable {
/// 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.
/// that the tab is one caption stating the fact, and **add-git directly under it** since the
/// 2026-08-07 reversal (`BoardGitAddAction`), where the Board Settings door stood for the week
/// the sheet existed. The fact-then-offer posture blessed 2026-08-06, restated for a surface
/// whose header is the tab label and whose offer is the control itself rather than a door to it.
///
/// **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.
/// **opt-in per board**: the button is an offer, never an auto-init.
case noRepository
/// 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 nothing setup-shaped either, since
/// nothing setup-shaped can apply (`BoardGitSetupSection.resolve`, empty here).
case repoNested
/// 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
/// (no action, no setup `BoardGitSetupSection.resolve` empty), but its own case so the view
/// renders its own honest prose rather than the nested sentence "unverifiable" is not
/// "nested".
case unverifiable
/// 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 (which carries New Branch again
/// since 2026-08-07), the abnormal-state explanation when the surface is held, and the commit
/// identity block. 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(mode: BoardGitMode) -> BoardGitSection {
@@ -89,14 +103,100 @@ enum BoardGitSection: Equatable, CaseIterable {
}
}
// MARK: - The setup inventory
/// **Which setup controls this tab hosts for one board** a pure function of the mode and of whether
/// the repository opens, so the rehomed inventory is provable without a popover on screen.
///
/// It is `BoardSettingsSection.resolve`'s successor, and deliberately its same shape: that enum was
/// the *sheet's* inventory (2026-07-31 2026-08-07) and it retired with the sheet, but the rule it
/// carried is about the **board**, not about the container, so it survives the reversal re-pointed at
/// the tab. Branch creation is not a case here for the same reason it was one there and is not now:
/// it went back into the switch menu (`BoardGitControls.branchRow`), which is a daily control with an
/// inline reveal rather than a standing form. pro-m2's remote and credential cards each add a case
/// here and a block in `BoardGitTabView.posture` nothing else.
///
/// Ordered as the tab lays them out, and the order is trivially the postures' own: no board is ever
/// in both modes, so the array is one element or none. It stays an array rather than an `Optional`
/// because the pro-m2 cards land in the git-mode posture beside `.commitIdentity`.
enum BoardGitSetupSection: String, Equatable, CaseIterable, Identifiable {
/// Mode `none`: **add-git** (06-history-undo.md Rules Opt-in init) the offer, on every tier
/// since 12-editions.md PIVOT 2026-08-07, and still never an auto-init.
case addGit
/// Mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
/// (06 Interaction with external writers).
case commitIdentity
var id: String { rawValue }
/// The block's header a heading VoiceOver navigates by (10-accessibility.md's navigable-header
/// rule, carried over from the sheet's sections). `.addGit` has none: it renders directly under
/// the no-repository note, which already states what the posture is, and a "Git" header inside
/// the Git tab would be the surface naming itself twice (this file's opening note).
var title: String? {
switch self {
case .addGit: nil
case .commitIdentity: "Commit Identity"
}
}
/// - Parameter isRepositoryUnreadable: whether the board's `.git` exists and will not open
/// (`HistoryStore.isRepositoryUnreadable`). Defaulted, because it can only ever be true in mode
/// `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.
///
/// `nonisolated` for `BoardGitControls.switchTargets`' reason: a pure answer over plain values,
/// reachable from a test with no actor to hop to.
nonisolated static func resolve(
mode: BoardGitMode,
isRepositoryUnreadable: Bool = false
) -> [BoardGitSetupSection] {
switch mode {
case .none:
return [.addGit]
case .git:
// **An unreadable repository hosts no setup** (06-history-undo.md Rules, the
// corrupt-`.git` loud failure, ruled 2026-07-31: "the whole git surface paused
// Lanework leaves the repository untouched"), and it lands on repo-nested's emptiness by
// repo-nested's own reasoning, one step further along: writing an identity is a *write*
// into the repository's own config, and there is no repository the app can open to write
// it into. The posture is not empty, though that is the difference the sheet could not
// express and the tab can: `BoardGitControls` still renders, holding, with its own
// sentence explaining the state (`BoardGitBranchSurface.unreadableNote`). What the
// unreadable board loses is the setup block alone.
//
// The mode stays `.git` throughout this is emptiness *within* git mode, never a fall
// to mode none, which is what would let add-git be offered against an existing `.git`.
return isRepositoryUnreadable ? [] : [.commitIdentity]
case .repoNested:
// **Nothing setup-shaped can apply** (06 Rules): the board lives inside a repository
// Lanework leaves alone, so there is no add-git (the design is insistent that the option
// is *absent*, "prose, not a disabled button") and no repo-local config of ours to write.
// The posture's whole content is its explanation.
return []
case .unverifiable:
// **Structurally the same emptiness as `.repoNested`, for the same reason** (06 Rules
// Detection, "Denial is not absence"): a denied ancestor check can never be told apart
// from a repository actually being there, so add-git stays unreachable. Only the
// posture's *prose* tells the two apart this inventory does not, because there is
// nothing to set up on either.
return []
}
}
}
// MARK: - The notes
/// **The no-repository caption** (03-board-ui.md Board popover Git tab) the fact, stated, above
/// the Board Settings door.
/// the add-git button that offers to change it.
///
/// One sentence in its siblings' register, and deliberately not a header: the tab label already says
/// "Git", so what is left to say is what this board's git story currently *is*. "Yet" is the whole
/// posture in a word the door directly below it is where a user says otherwise.
/// posture in a word the button directly below it is where a user says otherwise. (It read "above
/// the Board Settings door" between 2026-07-31 and the 2026-08-07 reversal; the sentence never
/// changed, only what stands under it.)
private struct BoardGitNoRepositoryNote: View {
var body: some View {
@@ -135,8 +235,8 @@ private struct BoardGitUnverifiableNote: View {
// MARK: - The tab
/// The Git tab's surface: whichever of the four postures this board is in, and the Board Settings
/// door where it applies.
/// The Git tab's surface: whichever of the four postures this board is in, with the setup controls
/// that posture hosts rendered inline in it (`BoardGitSetupSection`).
struct BoardGitTabView: View {
let store: BoardStore
@@ -152,21 +252,11 @@ struct BoardGitTabView: View {
/// *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 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.
let inset: CGFloat
/// **The popover's own dismissal**, used by exactly one control: the Board Settings row, whose
/// job is to close this surface and open the sheet. The popover is presented by `isPresented`, so
/// the environment action drives the same flag the widget's button does nothing here has to be
/// handed the widget's binding to put it down.
@Environment(\.dismiss) private var dismiss
var body: some View {
posture
.padding(inset)
@@ -181,11 +271,16 @@ struct BoardGitTabView: View {
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
// the honest signpost that this board *could* have a history and where to say so.
// offer. Add-git spent a week behind the settings sheet's door (2026-07-31 the
// 2026-08-07 reversal) and is back inline under the note that says why it is there. A
// `nil` git previews, the accessory-installation tests renders the note alone: the
// fact is the honest thing to say about a board nothing has been detected about, and
// there is nothing to add a repository *to*.
VStack(alignment: .leading, spacing: 6) {
BoardGitNoRepositoryNote()
boardSettingsRow
if let git, setup.contains(.addGit) {
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
}
}
case .repoNested:
@@ -195,40 +290,59 @@ struct BoardGitTabView: View {
BoardGitUnverifiableNote()
case .branch:
VStack(alignment: .leading, spacing: 6) {
// The daily face first the branch line with its switch menu, which carries New Branch
// again since the 2026-08-07 reversal then the one setup block this posture hosts.
// The identity block is gated on the setup inventory rather than on a condition spelled
// out here, which is what keeps "an unreadable repository hosts no setup" one rule with
// one test (`BoardGitSetupSection.resolve`); `BoardGitControls`' own unreadable sentence
// stands alone under it.
//
// `inset` as the gap rather than the 6pt row rhythm: the identity block is a *section*
// under its own heading, and the popover's section spacing is the figure the retired
// sheet used between its sections for the same reason (`BoardInfoView.inset`).
VStack(alignment: .leading, spacing: inset) {
if let git {
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
}
boardSettingsRow
if let git, setup.contains(.commitIdentity) {
setupBlock(.commitIdentity) {
BoardGitIdentityFields(git: git, isEnabled: store.acceptsBoardMutations)
}
}
}
}
}
/// **The popover's one setup affordance** (03-board-ui.md Board popover) the sheet's first
/// door, the menu row being the second (11-command-nexus.md).
///
/// **Shown only where the sheet is reachable** (`BoardSettingsAvailability`): this tab describes
/// *this board*, so a row pointing at a surface this board cannot have would be the disabled
/// button 06 rules out one level up. The menu row is the opposite case and stays visible a menu
/// is an inventory of the app.
///
/// **Dismiss first, then present.** The popover is transient and the sheet is not; leaving a
/// transient surface hanging over a modal one would read as two surfaces arguing about which the
/// user is in.
///
/// Not disabled by the read-only lock: opening a configuration surface is not a mutation, and the
/// controls inside it disable themselves (the Board Info I rule).
@ViewBuilder
private var boardSettingsRow: some View {
if let settings, BoardSettingsAvailability.resolve(
/// What this board's setup half holds the sheet's inventory rule, re-pointed at the tab it
/// rehomed into (`BoardGitSetupSection`). Read once per posture branch rather than re-derived
/// beside each block.
private var setup: [BoardGitSetupSection] {
BoardGitSetupSection.resolve(
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
) {
Button("Board Settings…") {
dismiss()
settings.present()
)
}
.accessibilityHint("Opens the board settings sheet")
/// A setup block under its own header, where the section carries one.
///
/// **The header is an accessibility structure, not decoration** (10-accessibility.md's
/// navigable-header rule, which the retired sheet's sections carried and which came along with
/// them): the rotor jumps between headings rather than walking one flat run of controls. This is
/// the tab's *only* header the no-repository posture's control has none, and the tab label
/// still does the naming for the surface as a whole (this file's opening note).
@ViewBuilder
private func setupBlock(
_ section: BoardGitSetupSection,
@ViewBuilder content: () -> some View
) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let title = section.title {
Text(title)
.font(.subheadline.weight(.semibold))
.accessibilityAddTraits(.isHeader)
}
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
+63 -36
View File
@@ -10,8 +10,10 @@ import SwiftUI
/// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color /
/// Pattern picker (the Background tab's original name, before the same session widened it past the
/// generated-only picker and folded manual styling back out to Style S); and `BoardGitTabView`,
/// which the pre-tab body's mode-aware git section rehomed into whole postures, notes, and the
/// Board Settings row, none of them re-ruled by the move.
/// which the pre-tab body's mode-aware git section rehomed into whole postures and notes, none of
/// them re-ruled by the move. That tab carried a **Board Settings** row to a separate sheet until
/// later the same day, when the 2026-07-31 popover/sheet split was reversed and the sheet's contents
/// rehomed into the tab (see below).
///
/// **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
@@ -24,6 +26,14 @@ import SwiftUI
/// and a second entry would muddy it"). It has exactly two ways in: the widget, and File Board
/// Info I, which is the same widget's popover reached from the keyboard (11-command-nexus.md's
/// class **C** "the keyboard path is reachability not bindings").
///
/// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31
/// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and
/// a row here pointing at it. The sheet is retired, its add-git and commit-identity controls render
/// inline in the Git tab's postures (`BoardGitSetup.swift`), branch creation went back into the
/// switch menu (`BoardGitControls`), and Board Board Settings left the menu bar with them. So
/// "one home per control" the split's own promise is now satisfied by there being one surface,
/// and I is the door to all of it.
// MARK: - Presentation state
@@ -64,8 +74,17 @@ extension FocusedValues {
// MARK: - The window-title widget
/// 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 titlebar widget: the board's glyph beside a two-line identity block its name, and on a
/// git-mode board its branch under the name with a trailing disclosure chevron, whose one job is
/// this popover.
///
/// **A two-line stack since 2026-08-07** (03-board-ui.md Board popover, the window-title widget
/// passage). It was one line reading `glyph Title branch `, and the em-dash was the tell: a
/// separator doing a *hierarchy's* job, with the branch competing for the same width as the name it
/// qualifies. So the branch moved under the title in its own smaller, secondary line, the em-dash
/// retired, and the glyph grew to span both lines an icon sized to the block it labels rather than
/// to whichever line it happened to sit on. A board with no branch is the single title line, vertically
/// centred beside the same glyph, which is the same block with one row.
///
/// **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
@@ -94,12 +113,6 @@ struct BoardInfoWidget: View {
@Bindable var presentation: BoardInfoPresentation
/// The window's settings sheet, so the popover's Git tab can carry the **Board Settings**
/// row that opens it (03-board-ui.md Board popover: "A Board Settings row opens the sheet
/// the popover's one setup affordance"). `nil` where there is no window to present a sheet on,
/// which is the accessory-installation tests' shape and reads as a popover with no row.
let settings: BoardSettingsPresentation?
/// The widget's two strings, computed fresh on every body evaluation rather than cached anywhere.
/// That matters here specifically: `boardInfoTitlebarAccessory` builds this view exactly **once**
/// at install, so a value read anywhere but inside `body` would freeze at the widget's birth and
@@ -118,19 +131,31 @@ struct BoardInfoWidget: View {
Button {
presentation.toggle()
} label: {
HStack(spacing: 4) {
HStack(spacing: 6) {
// The board's own glyph beside its name the identity pair the popover header
// states, restated where the board is named all day (2026-08-07). Read inside
// `body` for `summary`'s reason: `icon`/`iconColor` are `@Observable` fields, so a
// restyle from the popover repaints this widget without reinstalling it. Lenient on
// both dimensions an unresolvable glyph draws the board default, an unresolvable
// tint draws the standard secondary.
//
// **Its own font, not the container's** (the two-line rework, 2026-08-07): at 22pt
// it draws about twice the height `.imageScale(.small)` gave it on the container's
// 13pt, which is what lets one glyph span the title and branch lines instead of
// sitting beside the upper one. The number is the icon's own size rather than a
// scale factor because that is the dimension being chosen the block's height.
Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board))
.imageScale(.small)
.font(.system(size: 22))
.foregroundStyle(iconTint)
// Decorative beside the name it repeats the button's own label already says
// everything VoiceOver needs (`accessibilityLabel` below).
.accessibilityHidden(true)
// The identity block: the name, and the branch beneath it on a git-mode board. On
// any other board this is the single title line and the `HStack`'s own centring puts
// it level with the glyph the stack is the same shape with one row, never a
// special case.
VStack(alignment: .leading, spacing: 1) {
Text(summary.title)
// Styled like a titlebar title, because that is what it now stands in for
// (`BoardWindowHost` hides the system title display in favor of this widget).
@@ -138,19 +163,22 @@ struct BoardInfoWidget: View {
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
// Yields space to the branch string and chevron first when the two don't both
// fit inside the width cap below the board's own name is the more load-bearing
// half of the pair.
.layoutPriority(1)
if let branch = summary.branch {
Text("")
.foregroundStyle(.secondary)
// Smaller and secondary the qualifier under the name it qualifies. The
// em-dash that separated the two on one line retired with the stack: a
// hierarchy that a layout can state does not need punctuation to state it.
Text(branch)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
// Yields space to the chevron first when the block doesn't fit inside the width cap
// below the board's identity is the more load-bearing half of the pair, and both
// its lines truncate rather than the disclosure disappearing.
.layoutPriority(1)
Image(systemName: "chevron.down")
.imageScale(.small)
@@ -159,10 +187,12 @@ struct BoardInfoWidget: View {
}
.font(.system(size: 13))
// A long board name (or branch) must not swallow the whole titlebar capped rather
// than left to grow, with the truncation above doing the rest. Height stays the
// original chevron's, which is what keeps the accessory titlebar-appropriate.
// than left to grow, with the truncation above doing the rest. The height is the
// two-line block's (2026-08-07; it was the original chevron's 18 while the widget was
// one line), which is what keeps the accessory titlebar-appropriate: tall enough for
// name-over-branch, and no taller than a standard title bar carries.
.frame(maxWidth: 400, alignment: .leading)
.frame(height: 18)
.frame(height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -176,7 +206,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, git: git, settings: settings)
BoardInfoView(store: store, recents: recents, git: git)
}
}
@@ -246,24 +276,22 @@ struct BoardInfoTitlebarSummary: Equatable {
/// window, removed on detach for the same reason it owns the delegate proxying: the window is
/// SwiftUI's, and anything hung on it has to be taken back off.
@MainActor
/// `git` defaults to no session 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.
/// `git` defaults to no session, so that a caller with none in hand (the accessory-installation
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
/// than by accident: a Git tab in its no-repository posture, and a widget with no branch line. The
/// app's own call site passes the session's value explicitly.
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
git: HistoryStore? = nil,
presentation: BoardInfoPresentation,
settings: BoardSettingsPresentation? = nil
presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
store: store,
recents: recents,
git: git,
presentation: presentation,
settings: settings
presentation: presentation
)
)
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
@@ -272,8 +300,10 @@ func boardInfoTitlebarAccessory(
// widget's real content replaces it but that first pass is exactly what a 20×18 placeholder
// (the old chevron-only width) would clamp now that the widget's content can run out to 400pt:
// wide enough that the widest realistic first paint is never visibly clipped before the resize.
// The height is the widget's own two-line figure (2026-08-07), for the same reason the width is
// generous a first pass clamped to the old one-line 18 would paint a clipped block.
hosting.sizingOptions = [.intrinsicContentSize]
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 18)
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 32)
let controller = NSTitlebarAccessoryViewController()
controller.view = hosting
@@ -321,7 +351,6 @@ struct BoardInfoView: View {
let store: BoardStore
let recents: StyleRecents
let git: HistoryStore?
let settings: BoardSettingsPresentation?
/// The selected tab, and **it resets to Info on every open** a ruling, not an accident (the
/// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover
@@ -341,13 +370,11 @@ struct BoardInfoView: View {
init(
store: BoardStore,
recents: StyleRecents,
git: HistoryStore? = nil,
settings: BoardSettingsPresentation? = nil
git: HistoryStore? = nil
) {
self.store = store
self.recents = recents
self.git = git
self.settings = settings
}
var body: some View {
@@ -416,7 +443,7 @@ struct BoardInfoView: View {
case .theme:
BoardThemeTabView(store: store, inset: inset)
case .git:
BoardGitTabView(store: store, git: git, settings: settings, inset: inset)
BoardGitTabView(store: store, git: git, inset: inset)
}
}
// The style editor's popover width, taken from the editor rather than restated the number
-666
View File
@@ -1,666 +0,0 @@
import SwiftUI
/// **The board settings sheet** "the setup home" (03-board-ui.md Board settings sheet, ruled
/// 2026-07-31: the popover/sheet split, 04-interactions.md's configuration carve-out).
///
/// ### Why there are two configuration surfaces and not one
///
/// The split's reasons are mechanical, not aesthetic (04 The map): setup flows fire confirmation
/// alerts, run inline network probes, and accept drag-in key import "acts that need a surface a
/// stray click can't dismiss". So the **popover** keeps the daily face (rename, styling, the branch
/// display and its switch picker, the posture lines) and this sheet takes everything setup-shaped.
/// **Each control has exactly one home**: "the popover never duplicates a sheet control, the sheet
/// never hosts the daily surface."
///
/// ### What is here, and what joins it
///
/// Three sections ship with the sheet itself, and each of them *moved* here rather than being written
/// here: **add-git** (mode `none`), **branch creation** (mode `git`), and the **commit-identity**
/// fields (mode `git`). 03's inventory for this surface is longer add/change remote with its inline
/// verify probe, the HTTPS credential fields, the whole SSH surface with its drag-in key import and
/// TOFU confirms, push-on-commit and every one of those is a pro-m2 card that joins as **one more
/// section** (`BoardSettingsSection`), which is the only thing the shape here has to promise.
///
/// ### Undo routing needs nothing from this file
///
/// "The settings sheet's text fields own Z/Z as field-local text undo while focused"
/// (06-history-undo.md Undo routing). A SwiftUI sheet is hosted in its own `NSWindow`, so a focused
/// field's editor supplies its own undo manager through the responder chain exactly as the popover's
/// rename field does the platform's first-responder rule, working by construction. Nothing here
/// wires it, and nothing here may quietly take it away: a board-level Z reaching a typo would be
/// 06's "reflexive undo over a typo must never become a tree checkout".
// MARK: - Presentation state
/// Whether **this window's** settings sheet is open, and the session posture that decides whether it
/// may open at all.
///
/// `BoardInfoPresentation`'s sibling in every respect (see it for why the flag is per *window* rather
/// 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 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
/// 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 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] {
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. Derived from `sections` rather than from `BoardSettingsAvailability` directly, so the
/// unadopted case above cannot answer one way here and another there.
var isReachable: Bool {
!sections.isEmpty
}
/// **Opening, not toggling** unlike I. A sheet is modal to its window and carries its own
/// dismissal (Done, and Escape through it), so a command that could also *close* it would be a
/// second exit for a surface that already has the platform's; and neither door is reachable while
/// the sheet is up anyway (the menu is behind it, the popover is dismissed by it).
///
/// The guard is not defensive dressing: both doors validate on `isReachable` already, and a
/// third path that forgot to would present a sheet with no sections in it.
func present() {
guard isReachable else { return }
isPresented = true
}
func dismiss() {
isPresented = false
}
}
/// The focused board window's settings sheet, beside `FocusedValues.boardInfo` see
/// `FocusedBoardStoreKey` for why board-window menu items reach their window this way.
struct FocusedBoardSettingsKey: FocusedValueKey {
typealias Value = BoardSettingsPresentation
}
extension FocusedValues {
var boardSettings: BoardSettingsPresentation? {
get { self[FocusedBoardSettingsKey.self] }
set { self[FocusedBoardSettingsKey.self] = newValue }
}
}
// MARK: - What the sheet holds
/// **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 {
/// 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
/// 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
/// Mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
/// (06 Interaction with external writers).
case commitIdentity
var id: String { rawValue }
/// The section header a heading VoiceOver navigates by (10-accessibility.md Board settings
/// sheet: "titled and sectioned with headers VoiceOver can navigate by").
var title: String {
switch self {
case .git: "Git"
case .branch: "Branch"
case .commitIdentity: "Commit Identity"
}
}
/// - Parameter isRepositoryUnreadable: whether the board's `.git` exists and will not open
/// (`HistoryStore.isRepositoryUnreadable`). Defaulted, because it can only ever be true in mode
/// `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(
mode: BoardGitMode,
isRepositoryUnreadable: Bool = false
) -> [BoardSettingsSection] {
switch mode {
case .none:
return [.git]
case .git:
// **An unreadable repository hosts no setup either** (06-history-undo.md Rules, the
// corrupt-`.git` loud failure, ruled 2026-07-31: "the whole git surface paused
// Lanework leaves the repository untouched"), and it lands on repo-nested's emptiness by
// repo-nested's own reasoning, one step further along: 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. An empty sheet would be the
// greyed-out button 06 rules out one level up, so the surface simply does not exist for
// such a board and the popover's own prose carries the explanation
// (`BoardGitBranchSurface.unreadableNote`).
//
// The mode stays `.git` throughout this is emptiness *within* git mode, never a fall
// to mode none, which is what would let add-git be offered against an existing `.git`.
return isRepositoryUnreadable ? [] : [.branch, .commitIdentity]
case .repoNested:
// **Nothing setup-shaped can apply** (06 Rules): the board lives inside a repository
// Lanework leaves alone, so there is no add-git (the design is insistent that the option
// is *absent*, "prose, not a disabled button"), no branch of ours to create, and no
// repo-local config of ours to write. An empty sheet would be the greyed-out button one
// level up so the popover's prose stands and this surface simply does not exist for
// such a board.
return []
case .unverifiable:
// **Structurally the same emptiness as `.repoNested`, for the same reason** (06 Rules
// Detection, "Denial is not absence"): a denied ancestor check can never be told apart
// from a repository actually being there, so add-git stays unreachable and this surface
// does not exist for such a board either. Only the popover's *prose* tells the two apart
// this inventory does not, because the sheet has nothing to set up on either.
return []
}
}
}
/// **Whether the sheet is reachable at all**, for both of its doors Board Board Settings's
/// `disabled` state and whether the popover renders its Board Settings row.
///
/// 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: 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 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
/// the sheet is reachable**: a menu is an inventory of the app, a popover section is a description of
/// this board.
enum BoardSettingsAvailability {
static func resolve(
mode: BoardGitMode,
isRepositoryUnreadable: Bool = false
) -> Bool {
!BoardSettingsSection.resolve(
mode: mode,
isRepositoryUnreadable: isRepositoryUnreadable
).isEmpty
}
}
// MARK: - The sheet
/// The sheet itself: a header naming it and the board, the sections, and one Done.
struct BoardSettingsSheet: View {
let store: BoardStore
let presentation: BoardSettingsPresentation
private var bodyPointSize: CGFloat { BoardMetrics.bodyPointSize }
private var width: CGFloat {
BoardSettingsSheetLayout.width(bodyPointSize: bodyPointSize)
}
private var inset: CGFloat {
BoardSettingsSheetLayout.inset(bodyPointSize: bodyPointSize)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
// No scroll container, deliberately: three sections fit any screen at any text size, and
// a `ScrollView` inside a content-sized sheet has to be given a height a decision worth
// making when pro-m2's credential and SSH sections make it real, not before.
VStack(alignment: .leading, spacing: inset) {
ForEach(presentation.sections) { section in
self.section(section)
}
}
.padding(inset)
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
footer
}
.frame(width: width)
}
// MARK: The chrome
/// **Titled** (10-accessibility.md Board settings sheet) the surface's own name, plus the
/// board's so a user with two boards open knows which one this is about.
///
/// The board's name is `AppModel.displayName(of:)` the title-falls-back-to-the-folder-name rule
/// (01-storage-format.md Board naming), called rather than restated: `BoardInfoTitlebarSummary`
/// restates it only because it must answer without a `BoardStore`, and this sheet has one.
private var header: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Board Settings")
.font(.headline)
.accessibilityAddTraits(.isHeader)
Text(AppModel.displayName(of: store))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(inset)
}
/// **The one exit, twice** the button and Escape.
///
/// `.cancelAction` is what wires Escape to it, and the naming is deliberate rather than sloppy:
/// nothing on this sheet is staged, so there is nothing a Cancel could roll back every control
/// writes when it is used, and the button says Done because that is what dismissing means here.
/// Not `.defaultAction`, so Return stays the focused field's (the branch name field submits with
/// it).
private var footer: some View {
HStack {
Spacer()
Button("Done") {
presentation.dismiss()
}
.keyboardShortcut(.cancelAction)
}
.padding(inset)
}
// MARK: The sections
@ViewBuilder
private func section(_ section: BoardSettingsSection) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(section.title)
.font(.subheadline.weight(.semibold))
// What "sectioned" buys a VoiceOver user: headings the rotor jumps between, rather
// than one flat run of controls (10 Board settings sheet "the sheet exists partly
// *because* Tab-walking two dozen controls in an untitled popover failed this bar").
.accessibilityAddTraits(.isHeader)
if let git = presentation.git {
switch section {
case .git:
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
case .branch:
BoardSettingsBranchSection(git: git, isEnabled: store.acceptsBoardMutations)
case .commitIdentity:
BoardGitIdentityFields(git: git, isEnabled: store.acceptsBoardMutations)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Geometry
/// The sheet's two figures, **derived from the body font** like every other surface's
/// (10-accessibility.md Text scaling: "relative text styles everywhere, no fixed point sizes"), and
/// stated here rather than inline so they are one decision.
///
/// A sheet is a window the app sizes, so a width it does not choose is a width AppKit derives from
/// whatever the widest control happened to be which would move every time a section joined. The
/// figure is wide enough for a labeled two-column form (the identity fields) and narrower than the
/// board window's own floor, so the sheet reads as a card on the window rather than as a second one.
enum BoardSettingsSheetLayout {
/// 30 em: 390pt at the standard 13pt body.
static func width(bodyPointSize: CGFloat) -> CGFloat {
BoardMetrics.em(30, bodyPointSize: bodyPointSize)
}
/// The sheet's inset **and** the gap between two sections one figure, because a section's
/// distance from its neighbour and from the sheet's edge are the same rhythm. 1.55 em: 20pt at
/// the standard body.
static func inset(bodyPointSize: CGFloat) -> CGFloat {
BoardMetrics.em(1.55, bodyPointSize: bodyPointSize)
}
/// The identity form's label column. 3.4 em: 44pt at the standard body, which is what those
/// fields have always drawn.
static func labelColumn(bodyPointSize: CGFloat) -> CGFloat {
BoardMetrics.em(3.4, bodyPointSize: bodyPointSize)
}
}
// MARK: - Add git
/// **The add-git action** (06-history-undo.md Rules Opt-in init) the one place in the app that
/// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than
/// a promise: there is no other caller of `HistoryStore.addGit`.
///
/// The caption states what pressing it does, in the order it happens, because it is not undoable in
/// the ordinary sense: a repository appears in the board's folder and its current state becomes the
/// first commit.
///
/// **It moved here from the popover with the 2026-07-31 split** (03-board-ui.md Board settings
/// sheet: the sheet hosts "add-git (mode none; opt-in init 06)"), carrying the two lines below with
/// it.
private struct BoardGitAddAction: View {
let git: HistoryStore
/// The read-only lock's reach (02-architecture.md The lock's scope): a board that refuses
/// writes refuses this one too initializing a repository is a write, and a commit is several.
/// The sheet **stays open** under the lock and disables in place, which is the style popover's
/// settled precedent (03 Board settings sheet).
let isEnabled: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Button("Add Git") {
Task { await git.addGit() }
}
.disabled(!isEnabled || git.isAddingGit)
Text("Creates a git repository in this board's folder and commits its current state.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let failure = git.lastFailure {
Text(failure.message)
.font(.caption)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
// **The form add-git answers at** (06 Interaction with external writers, ruled 2026-07-31
// "Form-anchored operations answer at the form first"): inline while this sheet is up, the
// banner once it is gone. Appearing claims the inline surface; disappearing gives it up,
// which both dismisses the stale error and sends any answer still in flight to the banner
// instead of to nobody.
//
// The section's visibility *is* the sheet's here, and stays so as pro-m2's sections arrive:
// add-git exists only on mode `none`, and a successful one flips the mode which is the one
// disappearance that is not a dismissal, and it is the right one (the failure slot empties
// because there is nothing left to fail).
.onAppear { git.noteFormVisible(true) }
.onDisappear { git.noteFormVisible(false) }
}
}
// MARK: - Branch creation
/// **Branch creation** (06-history-undo.md Branch switching; 03-board-ui.md Board settings sheet:
/// "branch creation (switching stays in the popover; create-and-switch runs 06's identical settle
/// sequence from here)").
///
/// ### A standing field, not a reveal
///
/// In the popover this was a "New Branch" entry inside the switch menu that revealed an inline field
/// the right shape *there*, where the surface is a compact daily face and the field was a detour off
/// it. A form is a form: this sheet exists to hold setup controls standing, so the field stands, and
/// the reveal dance retires with the container that motivated it. The Create button validates on a
/// non-empty trimmed name, which is the only thing the dance was ever gating.
///
/// ### The sequence is not this view's
///
/// `GitBranchSwitcher.createAndSwitch(to:)` runs the identical settle flush stamp switch
/// sequence the picker's switch does "no at-HEAD fast path" (06, blessed 2026-07-31) and this
/// view calls exactly that method. The save-or-discard step it may raise is an **alert over the
/// sheet**, which is one of the mechanical reasons the sheet exists at all: "confirmation alerts
/// present over the sheet without dismissing the flow that owns them" (03).
private struct BoardSettingsBranchSection: View {
let git: HistoryStore
let isEnabled: Bool
@State private var draft = ""
/// The same four facts the popover's branch line reads, so a paused repository, a read-only board
/// and a switch in flight close this control exactly as they close that one one rule, one
/// derivation (`BoardGitBranchSurface`).
private var surface: BoardGitBranchSurface {
BoardGitBranchSurface.resolve(
branch: git.branch,
pause: git.committer?.pause,
isSwitching: git.switcher?.isSwitching ?? false,
isWritable: isEnabled
)
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
TextField("New branch name", text: $draft)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.onSubmit { create() }
// **Escape steps outward one layer per press** (04-interactions.md Grammar),
// the rename field's rule: a dirty field abandons its draft and keeps the sheet
// up; an empty one lets the press through to the sheet's own dismissal.
.onKeyPress(.escape) {
guard !draft.isEmpty else { return .ignored }
draft = ""
return .handled
}
.accessibilityLabel("New branch name")
Button("Create", action: create)
.disabled(trimmedDraft.isEmpty)
if git.switcher?.isSwitching == true {
ProgressView()
.controlSize(.small)
.accessibilityLabel("Switching branches")
}
}
.disabled(!surface.controlsEnabled)
Text("Creates the branch from the current one and switches to it.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
// The switcher's last failure, said where it was asked for. The popover's own caption
// stays (a switch asked *there* answers there); the two can never show at once, since
// opening this sheet dismisses that popover.
if let failure = git.switcher?.lastFailure {
Text(failure.message)
.font(.caption)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
// The two reads `surface` needs that nothing else on this sheet takes the branch name and
// the pause asked when the sheet appears, because neither is a fact the board's watcher
// could deliver (`.git` is filtered out of the watch by design). The branch *list* is not
// read here: this section creates, and only the popover's picker needs to know what exists.
.task {
await git.refreshBranch()
await git.committer?.refreshPause()
}
}
private var trimmedDraft: String {
draft.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func create() {
let name = trimmedDraft
guard !name.isEmpty, surface.controlsEnabled else { return }
draft = ""
Task { await git.switcher?.createAndSwitch(to: name) }
}
}
// MARK: - Commit identity
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md Interaction
/// with external writers: "The board settings sheet's identity section exposes name/email fields
/// that write that repo-local config the setting *is* the file, portable to any git client,
/// per-board by nature").
///
/// It moved here whole from the popover with the 2026-07-31 split, poll included 06 says the
/// visibility-scoped re-read "rides with the fields", so hosting the view here *is* the re-point:
/// the `.task` below now lives and dies with the sheet.
///
/// ### The placeholder is the whole of the identity rule made visible
///
/// An empty field shows the **derived default** the macOS account's full name and
/// `shortname@hostname` as a placeholder, never as a value. That is the difference between "this
/// repository says nothing, so the app signs commits with a sensible guess" and "this repository says
/// this", and the file is where the difference lives: 06 forbids the app writing its own derived
/// value into config, because it would then outrank the user's global `~/.gitconfig` for their own
/// terminal commits in that board. A field pre-filled with the derived value would write it on the
/// first focus loss.
///
/// ### The dirty-buffer courtesy, copied from `BoardRenameField`
///
/// A foreign config edit landing while the sheet is open updates an *unfocused* field and never a
/// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md Board popover). The
/// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher`
/// filters `.git` out of the watch by design, so no board event can ever carry a config change, and
/// the alternative to a small periodic read is a field that is stale for as long as the sheet stays
/// open.
private struct BoardGitIdentityFields: View {
let git: HistoryStore
let isEnabled: Bool
@State private var name = ""
@State private var email = ""
@FocusState private var focused: Field?
private enum Field: Hashable {
case name
case email
}
/// **The fields re-read the config at 2 s while the sheet is visible** (06 Interaction with
/// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event
/// can carry a terminal-side config edit the unfocused-resync courtesy needs its own signal, and
/// a visibility-scoped poll is the 15 s paused-state re-read's shape at sheet cadence (a focused
/// field keeps its keystrokes; dismissing the sheet stops the poll)."
private static let pollInterval: Duration = .seconds(2)
var body: some View {
VStack(alignment: .leading, spacing: 6) {
field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name)
field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email)
if let failure = git.identityFailure {
Text(failure.message)
.font(.caption)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
.task {
// The first read, then the courtesy poll. Cancellation is the view's disappearance, which
// is the sheet closing.
while !Task.isCancelled {
await git.refreshIdentity()
try? await Task.sleep(for: Self.pollInterval)
}
}
.onAppear {
name = git.identityName
email = git.identityEmail
}
.onChange(of: git.identityName) { _, value in
guard focused != .name else { return }
name = value
}
.onChange(of: git.identityEmail) { _, value in
guard focused != .email else { return }
email = value
}
// A dismissal is a commit like any other click-away `BoardRenameField`'s rule, and the same
// idempotence makes the overlap harmless.
.onDisappear { commit() }
}
private func field(
_ label: String,
text: Binding<String>,
placeholder: String,
tag: Field
) -> some View {
HStack(spacing: 6) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(
width: BoardSettingsSheetLayout.labelColumn(bodyPointSize: BoardMetrics.bodyPointSize),
alignment: .leading
)
TextField(placeholder, text: text)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($focused, equals: tag)
.onSubmit { commit() }
.disabled(!isEnabled)
.accessibilityLabel("Commit \(label.lowercased())")
}
.onChange(of: focused) { previous, _ in
// Focus leaving *this* field is this field's commit the inline editors' exit, applied
// to a form where Tab moves between two of them.
guard previous == tag else { return }
commit()
}
}
/// Writes both fields, and only when one of them differs from what the file says an unchanged
/// value must not rewrite `.git/config` every time the sheet closes.
private func commit() {
guard isEnabled else { return }
guard name != git.identityName || email != git.identityEmail else { return }
Task { await git.writeIdentity(name: name, email: email) }
}
}
+155
View File
@@ -0,0 +1,155 @@
import Foundation
import Testing
@testable import Kanban
/// **What the popover's Git tab sets up, board by board** (03-board-ui.md Board popover Git tab;
/// 06-history-undo.md Rules) `BoardGitSetupSection.resolve`, the tab's setup inventory, pinned
/// for `BoardGitSection.resolve`'s reason one seam over: the *contents* are the decision worth
/// asserting and the SwiftUI that renders them is not. Every case below is a plain value no board
/// on disk, no window, no session.
///
/// ### This suite is the settings sheet's, inherited
///
/// It was `BoardSettingsSectionTests` and `BoardSettingsAvailabilityTests` against the sheet's own
/// inventory (2026-07-31 2026-08-07). The **2026-08-07 reversal** retired that sheet and rehomed
/// its controls into the tab, so the *subject* moved and most of the rules did not: an unreadable
/// repository still hosts no setup, repo-nested and unverifiable still host none, and mode `none`
/// still means add-git and nothing else.
///
/// Two things did change, and both are asserted below rather than described:
///
/// - **Branch creation is not in this inventory.** It went back into the switch menu as New Branch
/// with an inline reveal (`BoardGitControls`), which is a daily control rather than a standing
/// form so the git-mode posture's setup half is the commit identity alone.
/// - **Emptiness no longer means "no surface".** The sheet's `BoardSettingsAvailability` existed to
/// answer "does this surface exist at all", because a sheet with no sections had no honest empty
/// state and two doors had to validate before opening it. The tab always exists (every board
/// carries all three tabs since 12-editions.md PIVOT 2026-08-07) and always has a posture to
/// render, so an empty inventory now means only "this posture hosts no setup block" there is no
/// door left to disable, and the availability seam retired with the doors.
@Suite("Board popover ▸ Git tab ▸ the setup inventory")
struct BoardGitSetupSectionTests {
@Test("Mode none: add-git and nothing else")
func modeNoneHoldsAddGit() {
// "add-git (mode none; opt-in init 06)", rendered under the no-repository note since the
// reversal put it back where the pre-split popover had it. Every tier's since
// 12-editions.md PIVOT 2026-08-07, and still an offer rather than an auto-init.
#expect(BoardGitSetupSection.resolve(mode: .none) == [.addGit])
}
@Test("Git mode: the commit identity — creation is the switch menu's again")
func gitModeHoldsTheIdentity() {
// "commit identity name/email (06 the visibility-scoped 2 s config re-read rides with the
// fields)". Branch creation was this posture's other section while the sheet existed; the
// reversal moved it back into the menu, so it is deliberately absent here.
#expect(BoardGitSetupSection.resolve(mode: .git) == [.commitIdentity])
}
@Test("Every section is reachable from some posture, and no posture invents one")
func theInventoryIsTotal() {
let offered = Set(BoardGitMode.allCases.flatMap { BoardGitSetupSection.resolve(mode: $0) })
#expect(offered == Set(BoardGitSetupSection.allCases))
}
@Test("Repo-nested: nothing setup-shaped applies — the posture is its explanation")
func repoNestedHoldsNothing() {
// "not a hidden 'add git' but a short explanation the option is absent because it *can't*
// apply" (06 Rules). The posture still renders its note is the whole of it but there
// is no setup block under it.
#expect(BoardGitSetupSection.resolve(mode: .repoNested) == [])
}
@Test("Unverifiable: the same emptiness, for the denial-not-absence reason")
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(BoardGitSetupSection.resolve(mode: .unverifiable) == [])
}
@Test("Git mode with an unreadable repository: no setup block, but still the branch posture")
func anUnreadableRepositoryHoldsNoSetup() {
// **The corrupt-`.git` loud failure** (06 Rules, ruled 2026-07-31): writing an identity is
// a *write* into the repository's own config, and there is no repository the app can open to
// write it into.
#expect(BoardGitSetupSection.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(BoardGitSetupSection.resolve(mode: .git) == [.commitIdentity])
// The difference the tab can express and the sheet could not: the *posture* is unchanged, so
// the board still gets its branch surface, holding and explaining itself
// (`BoardGitBranchSurface.unreadableNote`). Only the setup block goes.
#expect(BoardGitSection.resolve(mode: .git) == .branch)
}
@Test("Only the identity block carries a header")
func headersAreNamed() {
// 10-accessibility.md's navigable-header rule, carried over from the retired sheet's
// sections: a heading the VoiceOver rotor jumps to. Add-git has none it renders directly
// under the note that states the posture, and a "Git" header inside the Git tab would be the
// surface naming itself twice.
#expect(BoardGitSetupSection.commitIdentity.title == "Commit Identity")
#expect(BoardGitSetupSection.addGit.title == nil)
}
}
/// The same inventory asked about **a board on disk** the two ends of the range a real
/// `HistoryStore` puts into it, so the seam's inputs are shown to be the ones a composed session
/// actually produces rather than values a test invented.
///
/// `@MainActor` because composing a `HistoryStore` is (the inventory itself is `nonisolated`, which
/// is exactly what the suite above exercises with no actor in sight).
@MainActor
@Suite("Board popover ▸ Git tab ▸ the setup inventory, against a composed session")
struct BoardGitSetupCompositionTests {
@Test("A board carrying an unopenable `.git` is a git board with no setup")
func anInertGitIsAnUnreadableGit() 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))
// 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 no setup block, because every control there would write
// into something the app cannot open.
#expect(git.isRepositoryUnreadable)
#expect(
BoardGitSetupSection.resolve(
mode: git.mode,
isRepositoryUnreadable: git.isRepositoryUnreadable
) == []
)
}
@Test("A fresh mode-none board resolves to the add-git offer")
func aFreshBoardOffersAddGit() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
// 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)
#expect(
BoardGitSetupSection.resolve(
mode: git.mode,
isRepositoryUnreadable: git.isRepositoryUnreadable
) == [.addGit]
)
}
}
-209
View File
@@ -1,209 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// **The board settings sheet's two pure seams** (03-board-ui.md Board settings sheet, ruled
/// 2026-07-31 the popover/sheet split; 04-interactions.md The map's configuration carve-out):
/// what the sheet holds for a board, and therefore whether the sheet exists for that board at all.
///
/// 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("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. 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("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(mode: .git) == [.branch, .commitIdentity])
}
@Test("Every section is reachable from some posture, and no posture invents one")
func theInventoryIsTotal() {
let offered = Set(BoardGitMode.allCases.flatMap { BoardSettingsSection.resolve(mode: $0) })
#expect(offered == Set(BoardSettingsSection.allCases))
}
@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(mode: .unverifiable) == [])
}
@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(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(mode: .git) == [.branch, .commitIdentity])
}
@Test("The sections carry the headers VoiceOver navigates by")
func headersAreNamed() {
// 10-accessibility.md Board settings sheet: "titled and sectioned with headers VoiceOver
// can navigate by". The strings are the surface's spoken structure, so they are stated once
// and pinned once.
#expect(BoardSettingsSection.git.title == "Git")
#expect(BoardSettingsSection.branch.title == "Branch")
#expect(BoardSettingsSection.commitIdentity.title == "Commit Identity")
}
}
/// **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: a none-or-git board, and nowhere else")
func theMatrix() {
// A board whose mode leaves something to set up.
#expect(BoardSettingsAvailability.resolve(mode: .none))
#expect(BoardSettingsAvailability.resolve(mode: .git))
// **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(mode: .repoNested))
// **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))
// **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(mode: .git, isRepositoryUnreadable: true))
}
@Test("Reachable means exactly 'has something to show'")
func reachabilityIsTheInventory() {
// 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 mode in BoardGitMode.allCases {
#expect(
BoardSettingsAvailability.resolve(mode: mode)
== !BoardSettingsSection.resolve(mode: mode).isEmpty
)
}
}
}
/// **The window's sheet flag** (`BoardSettingsPresentation`) `BoardInfoPresentation`'s sibling,
/// with the one thing the popover flag does not carry: the session posture both doors validate on.
@MainActor
@Suite("Board settings sheet ▸ the window's presentation")
struct BoardSettingsPresentationTests {
@Test("It starts closed, unadopted, and unreachable")
func startsClosed() {
let presentation = BoardSettingsPresentation()
#expect(presentation.isPresented == false)
#expect(presentation.git == nil)
// 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)
}
@Test("An unreachable board's sheet refuses to present")
func presentRefusesWhereUnreachable() {
let presentation = BoardSettingsPresentation()
presentation.present()
#expect(presentation.isPresented == false, "an unadopted window has no sheet to open")
}
@Test("Two windows hold their own flags")
func perWindow() {
// `BoardInfoPresentation`'s rule, restated for the sheet: "two board windows each hold their
// own and can never toggle each other's".
let first = BoardSettingsPresentation()
let second = BoardSettingsPresentation()
first.isPresented = true
#expect(second.isPresented == false)
}
@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. 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(git: git)
#expect(presentation.isReachable)
#expect(presentation.sections == [.git])
presentation.present()
#expect(presentation.isPresented)
presentation.dismiss()
presentation.dismiss()
#expect(presentation.isPresented == false)
}
@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))
// 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(git: git)
#expect(presentation.isReachable == false)
#expect(presentation.sections.isEmpty)
}
}
+26 -64
View File
@@ -1,16 +1,21 @@
import Testing
@testable import Kanban
/// `caretChordsYield(boardInfo:boardSettings:search:)` 04-interactions.md Grammar's caret-chords
/// `caretChordsYield(boardInfo:search:)` 04-interactions.md Grammar's caret-chords
/// rule as one expression, and `BoardCommands.swift`'s single seam for it: Board Move Left/Move
/// Right / and the lane-width pair / disable via menu validation whenever *any* text
/// control has keyboard focus, because / are the standard line-start/end caret chords and an
/// enabled key equivalent fires before a field ever sees the key.
///
/// The function reads exactly three flags and nothing else, so every test here constructs
/// `BoardInfoPresentation`, `BoardSettingsPresentation` and `BoardSearchPresentation` directly rather
/// than through a `BoardStore` a fixture that stood up a board would be exercising machinery this
/// seam never touches.
/// The function reads exactly two flags and nothing else, so every test here constructs
/// `BoardInfoPresentation` and `BoardSearchPresentation` directly rather than through a `BoardStore`
/// a fixture that stood up a board would be exercising machinery this seam never touches.
///
/// **It read three until 2026-08-07.** The 2026-07-31 popover/sheet split gave the board settings
/// sheet its own flag here, because the branch-name and commit-identity fields had moved onto it; the
/// reversal retired that sheet and brought those fields back into the popover's Git tab, so the
/// popover's own open-at-all clause covers every configuration field again and the third disjunct
/// went with the surface it described.
@MainActor
@Suite("caretChordsYield ▸ the caret-chords rule")
struct CaretChordTests {
@@ -22,7 +27,7 @@ struct CaretChordTests {
// "Card-window fields need nothing: those windows never publish a boardStore, so both items
// are already scopeless there" (caretChordsYield's doc comment) nil/nil is that window's
// steady state, not a corner case.
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: nil) == false)
#expect(caretChordsYield(boardInfo: nil, search: nil) == false)
}
// MARK: The board popover, alone
@@ -30,46 +35,13 @@ struct CaretChordTests {
@Test("The board popover open yields; closed, it does not")
func popoverPresence() {
let boardInfo = BoardInfoPresentation()
#expect(caretChordsYield(boardInfo: boardInfo, boardSettings: nil, search: nil) == false, "closed by default")
#expect(caretChordsYield(boardInfo: boardInfo, search: nil) == false, "closed by default")
boardInfo.isPresented = true
#expect(caretChordsYield(boardInfo: boardInfo, boardSettings: nil, search: nil) == true)
#expect(caretChordsYield(boardInfo: boardInfo, search: nil) == true)
boardInfo.isPresented = false
#expect(caretChordsYield(boardInfo: boardInfo, boardSettings: nil, search: nil) == false, "closing re-enables the chords")
}
// MARK: The settings sheet, alone
@Test("The settings sheet open yields; closed, it does not")
func settingsSheetPresence() {
// The sheet joined this seam with the 2026-07-31 popover/sheet split: it is where the branch
// name field and the commit-identity fields live now, so the surface that used to be covered
// by "the popover is open" has to be covered by "the sheet is up" as well.
let settings = BoardSettingsPresentation()
#expect(caretChordsYield(boardInfo: nil, boardSettings: settings, search: nil) == false, "closed by default")
settings.isPresented = true
#expect(caretChordsYield(boardInfo: nil, boardSettings: settings, search: nil) == true)
settings.isPresented = false
#expect(
caretChordsYield(boardInfo: nil, boardSettings: settings, search: nil) == false,
"dismissing re-enables the chords"
)
}
@Test("The sheet's own reachability is not this seam's question — a presented sheet yields either way")
func presentationRatherThanReachability() {
// `isReachable` gates the two *doors* (`BoardSettingsAvailability`); this function reads the
// flag that says a sheet is on screen. A presentation left unadopted reads free-tier and
// therefore unreachable, and if something ever put its flag up anyway the chords must still
// yield what a caret chord competes with is a field that exists, not a tier.
let settings = BoardSettingsPresentation()
#expect(settings.isReachable == false, "unadopted reads as the free tier")
settings.isPresented = true
#expect(caretChordsYield(boardInfo: nil, boardSettings: settings, search: nil) == true)
#expect(caretChordsYield(boardInfo: boardInfo, search: nil) == false, "closing re-enables the chords")
}
// MARK: The search field, alone
@@ -77,13 +49,13 @@ struct CaretChordTests {
@Test("The search field focused yields; unfocused, it does not")
func searchFocus() {
let search = BoardSearchPresentation()
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: search) == false, "unfocused by default")
#expect(caretChordsYield(boardInfo: nil, search: search) == false, "unfocused by default")
search.isFocused = true
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: search) == true)
#expect(caretChordsYield(boardInfo: nil, search: search) == true)
search.isFocused = false
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: search) == false, "losing focus re-enables the chords")
#expect(caretChordsYield(boardInfo: nil, search: search) == false, "losing focus re-enables the chords")
}
// MARK: Both surfaces together
@@ -98,32 +70,22 @@ struct CaretChordTests {
focusedSearch.isFocused = true
// One side published and inert, the other absent (the still-loading-window shape): false.
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: nil, search: nil) == false)
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: unfocusedSearch) == false)
#expect(caretChordsYield(boardInfo: closedInfo, search: nil) == false)
#expect(caretChordsYield(boardInfo: nil, search: unfocusedSearch) == false)
// One side published and active, the other absent: true.
#expect(caretChordsYield(boardInfo: openInfo, boardSettings: nil, search: nil) == true)
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: focusedSearch) == true)
#expect(caretChordsYield(boardInfo: openInfo, search: nil) == true)
#expect(caretChordsYield(boardInfo: nil, search: focusedSearch) == true)
// Both published, both inert: false the popover being open at all and the field holding
// focus are each read independently, so neither's mere presence counts on its own.
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: nil, search: unfocusedSearch) == false)
#expect(caretChordsYield(boardInfo: closedInfo, search: unfocusedSearch) == false)
// Both published, one or both active: true. This is an `||`, not an `&&` one live text
// surface is enough to send the chords to it, whatever the other surface is doing.
#expect(caretChordsYield(boardInfo: openInfo, boardSettings: nil, search: unfocusedSearch) == true)
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: nil, search: focusedSearch) == true)
#expect(caretChordsYield(boardInfo: openInfo, boardSettings: nil, search: focusedSearch) == true)
// And the third surface composes the same way inert beside two inert siblings, sufficient
// beside them when it is up.
let closedSheet = BoardSettingsPresentation()
let openSheet = BoardSettingsPresentation()
openSheet.isPresented = true
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: closedSheet, search: unfocusedSearch) == false)
#expect(caretChordsYield(boardInfo: closedInfo, boardSettings: openSheet, search: unfocusedSearch) == true)
#expect(caretChordsYield(boardInfo: openInfo, boardSettings: openSheet, search: focusedSearch) == true)
#expect(caretChordsYield(boardInfo: openInfo, search: unfocusedSearch) == true)
#expect(caretChordsYield(boardInfo: closedInfo, search: focusedSearch) == true)
#expect(caretChordsYield(boardInfo: openInfo, search: focusedSearch) == true)
}
// MARK: Inline editors are a different seam
@@ -142,6 +104,6 @@ struct CaretChordTests {
// No board popover, no search focus caretChordsYield answers false regardless of the open
// editor above, because it never reads isEditingInline at all.
#expect(caretChordsYield(boardInfo: nil, boardSettings: nil, search: nil) == false)
#expect(caretChordsYield(boardInfo: nil, search: nil) == false)
}
}
+17 -48
View File
@@ -7,20 +7,28 @@ import XCTest
/// > runs in UI tests over every surface board (trash shown and hidden), card window (Preview, Edit,
/// > raw source), welcome, template chooser, board popover, board settings sheet.
///
/// The last of those is **one surface shorter than the design's sentence** since 2026-08-07: the
/// board settings sheet retired that day (03-board-ui.md Board settings sheet, marked retired
/// the 2026-07-31 popover/sheet split reversed), and everything it held now renders inside the board
/// popover's Git tab. So the popover's own audit is where those controls are looked at, and the
/// every-surface claim is satisfied by there being one surface fewer rather than by a test skipping
/// one.
///
/// One test per surface, one audit call each. `performAccessibilityAudit` audits **the app's
/// 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 board settings sheet is reachable since the 2026-08-07 pivot
/// ### The settings sheet's audit, and where it went
///
/// 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`.
/// 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). The 2026-08-07 pivot
/// (12-editions.md PIVOT 2026-08-07 git left the paywall) made it reachable and it gained a test
/// here; the **reversal later the same day retired the sheet outright**, and the test with it. What
/// remains is the precedent the no-bypass objection stands for whatever the next split gates and
/// one live consequence for this file: the surfaces that sheet held are now the board popover's Git
/// tab, which `testBoardInfoPopover` opens onto its Info tab. Auditing the Git tab specifically wants
/// a click on the popover's segmented strip and is a card of its own, filed rather than faked here.
///
/// ### No waiving
///
@@ -199,43 +207,4 @@ final class AccessibilityAuditTests: XCTestCase {
)
try app.performAccessibilityAudit()
}
// MARK: - The board settings sheet
/// **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 testBoardSettingsSheetOpensFromTheMenuAndAudits() throws {
let app = XCUIApplication.launchedWithFixtureBoard()
let bar = app.menuBars.firstMatch
let boardMenu = bar.menuBarItems["Board"]
XCTAssertTrue(
boardMenu.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the Board menu is missing from the menu bar"
)
boardMenu.click()
let row = bar.menuItems["Board Settings…"]
XCTAssertTrue(
row.waitForExistence(timeout: XCUIApplication.uiTimeout),
"Board ▸ Board Settings… is missing — the row ships in every mode that can host the sheet"
)
XCTAssertTrue(
row.isEnabled,
"a mode-none board hosts the sheet (add-git's home) — the pivot retired the tier gate that disabled this row"
)
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: [])
}
}
+9 -10
View File
@@ -30,7 +30,7 @@ Have a scratch board to hand for Part 2 — a new one from File ▸ New Board…
## Part 1 — run the audit suite
`KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over the surfaces the design names — nine of the ten automatically, the tenth (the **board settings sheet**) by hand in Part 3, because it is Pro-only and the fixture launch has no tier control. **Violations are test failures, not warnings**, and nothing is waived: the audits pass no issue handler at all.
`KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over the surfaces the design names — all of them automatically since 2026-08-07, when the **board settings sheet** retired and its controls rehomed into the board popover's Git tab (03-board-ui.md ▸ Board settings sheet, marked retired). The by-hand pass below is now about that tab, not about a surface the suite cannot reach. **Violations are test failures, not warnings**, and nothing is waived: the audits pass no issue handler at all.
```
xcodebuild test -project Kanban.xcodeproj -scheme Kanban \
@@ -38,7 +38,7 @@ xcodebuild test -project Kanban.xcodeproj -scheme Kanban \
-only-testing:KanbanUITests/AccessibilityAuditTests
```
The nine surfaces, and how each test gets there:
The surfaces, and how each test gets there:
| Test | Surface | Navigation |
| --- | --- | --- |
@@ -51,22 +51,21 @@ The nine surfaces, and how each test gets there:
| `testWelcomeWindow` | Welcome, with a recents row | Window ▸ Welcome to Lanework |
| `testTemplateChooser` | Template chooser | File ▸ New Board… |
| `testBoardInfoPopover` | Board popover | File ▸ Board Info |
| `testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture` | Board window, with Board ▸ Board Settings… checked | Opens the Board menu, asserts the row is present and disabled, closes it |
Every test launches the app with `--ui-test-fixture-board`, which makes the app build a known board inside its own container and open it — three lanes ("To Do", "Doing", "Done"), six cards, one card with a rich Markdown body, an attachment and a three-comment thread (one unattributed, one edited), one card already in the trash. That is the `standard` fixture variant; the bare flag means it, and the other two shapes (`large`, `malformed`) belong to the end-to-end pass. The board and the registry both live in a scratch directory that is wiped on every launch, so an audit run never touches your real boards or your recents list. See `Kanban/App/UITestLaunch.swift` for why the board cannot simply be handed to the app on the command line (the sandbox).
If a test fails, read the issue's `compactDescription` and fix the app. Adding a waiver is a design change and needs an entry on the Redesign board first.
### The board settings sheet, by hand
### The popover's Git tab, by hand
The sheet (03-board-ui.md ▸ Board settings sheet) needs **Pro on a board with app-managed git**, and there is deliberately no launch argument that grants Pro: `UITestLaunch` is compiled into the shipping binary, so a tier flag would be a subscription bypass anyone could type into Terminal. Until a fixture can reach Pro honestly, run this by hand once per release on a Pro build, with a git-mode board open:
The automated audit opens the popover on its **Info** tab, and switching tabs is a click the suite doesn't make yet — so the git surface's own keyboard and VoiceOver behaviour is a by-hand pass, once per release, with a git-mode board open. (This section was the **board settings sheet's** until 2026-08-07; the sheet retired and its controls came here, so the checks moved with them rather than being dropped.)
- [ ] **Open both doors.** Board ▸ Board Settings…, and the popover's **Board Settings…** row (File ▸ Board Info ▸ Git). The popover dismisses as the sheet appears — never both at once.
- [ ] **Sectioned, and navigable by heading.** With VoiceOver on, the rotor's heading list holds "Board Settings" and each section's title ("Branch", "Commit Identity"; "Git" on a board with no repository yet).
- [ ] **Tab reaches every control** with VoiceOver off and Full Keyboard Access on — the branch name field, Create, both identity fields, Done.
- [ ] **One door.** File ▸ Board Info (⌘I) ▸ **Git**. There is no second configuration surface and no Board ▸ Board Settings… row — both retired with the sheet.
- [ ] **Navigable by heading.** With VoiceOver on, the rotor's heading list holds "Commit Identity" on a git-mode board.
- [ ] **Tab reaches every control** with VoiceOver off and Full Keyboard Access on — the branch menu, the New Branch… field and Create when revealed, both identity fields.
- [ ] **⌘Z inside a field is the field's**, not the board's: type into Commit Identity ▸ Name, press ⌘Z, and the *typing* reverts — no tree checkout, no board step consumed (06-history-undo.md ▸ Undo routing).
- [ ] **Escape and Done both dismiss**; Escape in a dirty branch-name field clears the field first (one layer per press).
- [ ] **The read-only lock disables in place**: the sheet stays open and its controls grey out, with the banner naming why.
- [ ] **Escape steps outward one layer per press**: in a dirty New Branch… field it clears the field; in an empty one it closes the reveal; again, it dismisses the popover.
- [ ] **The read-only lock disables in place**: the popover stays open and its controls grey out, with the banner naming why.
## Part 2 — the VoiceOver smoke script