The Git tab fills in — the parked postures rehome whole, and the strip's membership is the posture
The popover's third tab settles (03 ▸ Git tab, 2026-08-07): BoardGitTabView takes the parked section — the six-posture matrix, the Pro pointer, the nested/unverifiable prose, the branch controls, the Board Settings… door — with the tab label doing the old "Git" header's naming work, and one new caption for the no-repository posture. BoardInfoTab.available drops the Git tab exactly where BoardGitSection resolves absent, carrying 12's absent-no-placeholder rule up to the tab strip; selection resets to Info per open, now a ruling. Daily face only — the remote block still joins with pro-m2. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
+15
-4
@@ -53,7 +53,7 @@ One **style editor** component — a background palette grid and a curated symbo
|
|||||||
|
|
||||||
## Board popover
|
## Board popover
|
||||||
|
|
||||||
**Restructure in progress (2026-08-07): the popover is going tabbed.** The symbol/name header stays at the top; below it sit three tabs — **Info**, **Theme**, **Git** — each the settings surface for one aspect of board configuration, each designed in its own dedicated session (Git is an empty placeholder until its; Info and Theme are settled). The bullets below describe the popover's pre-tab content; what of it rehomes into which tab — and where the git section lands — is the Git session's to settle. The rulings inside the bullets (rename semantics, the git postures, the popover/sheet split) stand; only their placement is in motion.
|
**The tabbed popover (2026-08-07, restructure complete — all three tab sessions settled).** The symbol/name header stays at the top — the board's glyph with its tint row beside the rename field; below it sit tabs — **Info**, **Theme**, **Git** — each the settings surface for one aspect of board configuration, each settled in its own dedicated session. **Tab membership is the git posture's** (the Git session's ruling): the Git tab joins the strip only when the git section has something true to say — `BoardGitSection` resolves to anything but absent — so a free-tier board with no `.git` shows Info | Theme alone. This carries 12-editions.md's "absent, no placeholder" rule up to the tab strip: a standing Git tab on every free board would be the standing ad for Pro that 12 forbids. **Selection resets to Info on every open** (ruled in the Git session, closing the question the earlier tab sessions deferred): the popover is transient and Info is the board's face — and a remembered tab could strand selection on a tab the next posture doesn't offer.
|
||||||
|
|
||||||
**The Theme tab** (settled 2026-08-07, its dedicated session; reworked same day from the first "Background" cut — the style-editor embed was in, then ruled out): a **preset-only** surface — no palette grid, no "Board" subtitle, no manual controls (the manual surface is Style… with nothing selected; the raw file remains the image escape hatch). One **Solid color / Pattern** segmented choice at the top, then the filters that apply to the chosen kind, then one carousel of the eight wheel hues with tall skinny chevrons flanking it (compact-chevron paging buttons, ~three swatches per press, each disabling at its end of the strip). **Solid color** shows Tone (defaults to the current appearance) and Saturation only; the carousel holds flat swatches — each hue at the selected tonality's base saturation and lightness, exactly the primary color the matching facets recipe would write — and clicking one sets `background.color` and *removes* `background.image` in one write (the generated PNG stays on disk so undo can restore the field that pointed at it). **Pattern** is the faceted-background picker (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, the reviewed recipe): four filters — Tone and Saturation shared with Solid, plus Colors mono/duo/trio and Mesh coarse/medium/fine — over rendered previews, one seed per hue, plus a Reroll button that re-mints the seeds (filters change the treatment, Reroll the geometry; preview and file share a seed, so what's clicked is what lands). Clicking a pattern swatch renders the recipe at the 3072 px decode ceiling off-main and lands it in one write: the PNG into the board root as `facets.png` (Finder-ladder rename only when a foreign file owns the name), `background.image` pointed at it, and `background.color` set to the recipe's primary color — the underlay that stands in while the image decodes or if the file ever goes missing. The mode opens on whichever kind the board currently wears (Pattern when `background.image` is the generated file, Solid otherwise). **Backgrounds ship as static pixels, never live-rendered views** (the perf/sync ruling, 2026-08-07): the generator runs at pick time, the render loop only ever composites a decoded bitmap. Native undo restores the two fields, not overwritten bytes — regenerating over our own PNG is destructive, documented, and accepted. The whole tab disables under the read-only lock as one surface.
|
**The Theme tab** (settled 2026-08-07, its dedicated session; reworked same day from the first "Background" cut — the style-editor embed was in, then ruled out): a **preset-only** surface — no palette grid, no "Board" subtitle, no manual controls (the manual surface is Style… with nothing selected; the raw file remains the image escape hatch). One **Solid color / Pattern** segmented choice at the top, then the filters that apply to the chosen kind, then one carousel of the eight wheel hues with tall skinny chevrons flanking it (compact-chevron paging buttons, ~three swatches per press, each disabling at its end of the strip). **Solid color** shows Tone (defaults to the current appearance) and Saturation only; the carousel holds flat swatches — each hue at the selected tonality's base saturation and lightness, exactly the primary color the matching facets recipe would write — and clicking one sets `background.color` and *removes* `background.image` in one write (the generated PNG stays on disk so undo can restore the field that pointed at it). **Pattern** is the faceted-background picker (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, the reviewed recipe): four filters — Tone and Saturation shared with Solid, plus Colors mono/duo/trio and Mesh coarse/medium/fine — over rendered previews, one seed per hue, plus a Reroll button that re-mints the seeds (filters change the treatment, Reroll the geometry; preview and file share a seed, so what's clicked is what lands). Clicking a pattern swatch renders the recipe at the 3072 px decode ceiling off-main and lands it in one write: the PNG into the board root as `facets.png` (Finder-ladder rename only when a foreign file owns the name), `background.image` pointed at it, and `background.color` set to the recipe's primary color — the underlay that stands in while the image decodes or if the file ever goes missing. The mode opens on whichever kind the board currently wears (Pattern when `background.image` is the generated file, Solid otherwise). **Backgrounds ship as static pixels, never live-rendered views** (the perf/sync ruling, 2026-08-07): the generator runs at pick time, the render loop only ever composites a decoded bitmap. Native undo restores the two fields, not overwritten bytes — regenerating over our own PNG is destructive, documented, and accepted. The whole tab disables under the read-only lock as one surface.
|
||||||
|
|
||||||
@@ -61,11 +61,22 @@ One **style editor** component — a background palette grid and a curated symbo
|
|||||||
|
|
||||||
The board's vital statistics, read-only, in two registers with one honest split: **model facts** off the live snapshot — Lanes, Cards, Attachments — counting *the board you see* (the welcome-count live-only rule; the Cards row grows a quiet "· N in Trash" tail only when the trash holds anything, counting freight the way the purge confirms do — never a standing "0 in Trash"); and **disk facts** off one background whole-folder walk — File (the `.board` folder's name), Files, Size, Created, Modified — counting *everything*, `.git` and `.trash/` included, because their job is to agree with Finder's Get Info about the same folder (a size that quietly excluded the repository would send a user hunting for missing gigabytes). Disk facts are honest-as-of-appearance, refreshed per tab visit, never live — `FolderWatcher` filters `.git` churn out of the reload stream by design, so there is no event they could honestly hang off, and a ticking size is motion without meaning on a settings surface; an em dash holds each disk row until the walk answers. Modified is the tree's newest content-modification date, directories included (a deletion-only change moves no file's mtime, only its parent folder's); Created is the folder's filesystem birth date, not frontmatter — the folder may predate any stamp in it. A **Reveal in Finder** link closes the tab — the rows describe the folder, and this is the door to it; not disabled under the read-only lock, since revealing is not a mutation.
|
The board's vital statistics, read-only, in two registers with one honest split: **model facts** off the live snapshot — Lanes, Cards, Attachments — counting *the board you see* (the welcome-count live-only rule; the Cards row grows a quiet "· N in Trash" tail only when the trash holds anything, counting freight the way the purge confirms do — never a standing "0 in Trash"); and **disk facts** off one background whole-folder walk — File (the `.board` folder's name), Files, Size, Created, Modified — counting *everything*, `.git` and `.trash/` included, because their job is to agree with Finder's Get Info about the same folder (a size that quietly excluded the repository would send a user hunting for missing gigabytes). Disk facts are honest-as-of-appearance, refreshed per tab visit, never live — `FolderWatcher` filters `.git` churn out of the reload stream by design, so there is no event they could honestly hang off, and a ticking size is motion without meaning on a settings surface; an em dash holds each disk row until the walk answers. Modified is the tree's newest content-modification date, directories included (a deletion-only change moves no file's mtime, only its parent folder's); Created is the folder's filesystem birth date, not frontmatter — the folder may predate any stamp in it. A **Reveal in Finder** link closes the tab — the rows describe the folder, and this is the door to it; not disabled under the read-only lock, since revealing is not a mutation.
|
||||||
|
|
||||||
The window-title widget opens the **board popover** — the one board-level surface, hosting:
|
### 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:
|
||||||
|
|
||||||
|
- **Free + inert `.git`** → the contextual one-line Pro pointer (12-editions.md — still the app's one in-context pointer to Pro, and still shown only where it is true).
|
||||||
|
- **Pro, 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.
|
||||||
|
- **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).
|
||||||
|
- **Absent** (free, no `.git`) → no tab at all — membership is the posture (the tabbed-popover paragraph above).
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
The window-title widget opens the **board popover** — the one board-level surface. 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).
|
- **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).
|
||||||
- **Board styling** — the embedded style editor (Styling ▸ Controls above).
|
- **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.
|
||||||
- **Git at a glance** — Pro tier surface, mode-aware, *display and daily operations only* (re-ruled 2026-07-31 — setup moved to the board settings sheet below; in the free tier this section is absent on ordinary boards and reduces to the contextual one-line Pro pointer on boards carrying an inert `.git` — 12-editions.md): the posture lines (repo-nested explanation, unreadable-repo and paused states — 06), branch display with the **switch picker**, and 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). **A single-branch board's picker opens onto a disabled explanatory row** (ruled 2026-08-06): 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. A **Board Settings…** row opens the sheet — the popover's one setup affordance. **The Pro mode-none posture is header plus that door** (blessed 2026-08-06): with no repository the section stays — "Git" and the Board Settings… row alone — rather than vanishing, so the board's git story keeps its named place in the popover and the door stands exactly where a user looking for git will look.
|
|
||||||
|
|
||||||
## Board settings sheet
|
## Board settings sheet
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
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).
|
||||||
|
///
|
||||||
|
/// ### No "Git" header anywhere in this tab
|
||||||
|
///
|
||||||
|
/// The tab's own label does the naming work the section's header used to (the design's phrasing), so
|
||||||
|
/// every posture below renders its content bare where the pre-tab section stacked it under a
|
||||||
|
/// `sectionHeader("Git")` and a `Divider()`. The tabs already sit under the popover header's own
|
||||||
|
/// divider; a second rule and a second "Git" would be the surface naming itself twice.
|
||||||
|
///
|
||||||
|
/// ### Membership is the posture, so this view never renders an empty state
|
||||||
|
///
|
||||||
|
/// `BoardInfoTab.available(tier:mode:hasGitDirectory:)` drops the Git tab from the strip exactly when
|
||||||
|
/// `BoardGitSection` resolves to `.absent`, so this view is never built for the free tier's ordinary
|
||||||
|
/// board. That is 12-editions.md's "absent, no placeholder" rule carried up to the tab strip — a
|
||||||
|
/// standing Git tab on every free board would be the standing ad for Pro that 12 forbids.
|
||||||
|
|
||||||
|
// MARK: - The posture
|
||||||
|
|
||||||
|
/// **What the Git tab shows, for one board** (03-board-ui.md ▸ Board popover ▸ Git tab;
|
||||||
|
/// 06-history-undo.md ▸ Rules; 12-editions.md ▸ The free tier and `.git`) — a pure function of two
|
||||||
|
/// facts, so the posture matrix is provable without a popover on screen.
|
||||||
|
///
|
||||||
|
/// The free tier's two cases are settled 2026-07-27: absent on an ordinary board, a one-line Pro
|
||||||
|
/// pointer on a board carrying an inert `.git`. The Pro cases are the mode, one to one — and the
|
||||||
|
/// mode-`none` and repo-nested pair is where the design is most insistent: a repo-nested board gets
|
||||||
|
/// **prose, not a disabled button**. "The option is absent because it *can't* apply, and the UI
|
||||||
|
/// should teach that rather than look broken" (06 ▸ Rules). `unverifiable` (the git-detection axis)
|
||||||
|
/// joins as a fourth Pro case, structurally identical to `repoNested` but worded as its own honest
|
||||||
|
/// prose — a denial is not a nesting.
|
||||||
|
///
|
||||||
|
/// **The 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-08-07 tab restructure rehomed the surface, not the matrix** — the same six cases, now
|
||||||
|
/// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's
|
||||||
|
/// single pane. `.absent` grew one further job in the move: it is what `BoardInfoTab.available` reads
|
||||||
|
/// to decide whether the Git tab is in the strip at all, which is why membership can never disagree
|
||||||
|
/// with what the tab would have rendered.
|
||||||
|
enum BoardGitSection: Equatable, CaseIterable {
|
||||||
|
|
||||||
|
/// Nothing at all — the free tier's ordinary board, where "the popover is rename + style,
|
||||||
|
/// complete in itself". Since the restructure this is a **missing tab**, not an empty one
|
||||||
|
/// (`BoardInfoTab.available`).
|
||||||
|
case absent
|
||||||
|
|
||||||
|
/// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer
|
||||||
|
/// to Pro (12 ▸ Tier naming).
|
||||||
|
case proPointer
|
||||||
|
|
||||||
|
/// Pro, mode `none`: a board that could have a history and has none. There is no daily surface
|
||||||
|
/// for that — the tab is one caption stating the fact above the Board Settings… door, where
|
||||||
|
/// add-git now lives (03 ▸ Board settings sheet). The header-plus-door posture blessed
|
||||||
|
/// 2026-08-06, restated for a surface whose header is now the tab label.
|
||||||
|
case noRepository
|
||||||
|
|
||||||
|
/// Pro, repo-nested: the honest explanation, no action — and no Board Settings… row either,
|
||||||
|
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
|
||||||
|
case repoNested
|
||||||
|
|
||||||
|
/// Pro, unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository
|
||||||
|
/// (06 ▸ Rules ▸ Detection, "Denial is not absence"). Structurally identical to `.repoNested`
|
||||||
|
/// (no action, no Board Settings… row, `BoardSettingsAvailability` false), but its own case so
|
||||||
|
/// the view renders its own honest prose rather than the nested sentence — "unverifiable" is not
|
||||||
|
/// "nested".
|
||||||
|
case unverifiable
|
||||||
|
|
||||||
|
/// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state
|
||||||
|
/// explanation when the surface is held, and the Board Settings… row. The remote half —
|
||||||
|
/// tracking, Pull/Push, the status badges — is 07-sync-collab.md's own card and joins this same
|
||||||
|
/// posture.
|
||||||
|
case branch
|
||||||
|
|
||||||
|
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
|
||||||
|
switch tier {
|
||||||
|
case .free:
|
||||||
|
// Detection never runs under the free tier, so the mode is not consulted here — the one
|
||||||
|
// question asked is whether the folder carries a `.git`, which is what the pointer is
|
||||||
|
// about (12: "any `.git` is inert … a stray like any other, preserved verbatim").
|
||||||
|
return hasGitDirectory ? .proPointer : .absent
|
||||||
|
case .pro:
|
||||||
|
switch mode {
|
||||||
|
case .none: return .noRepository
|
||||||
|
case .git: return .branch
|
||||||
|
case .repoNested: return .repoNested
|
||||||
|
case .unverifiable: return .unverifiable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The notes
|
||||||
|
|
||||||
|
/// **The no-repository caption** (03-board-ui.md ▸ Board popover ▸ Git tab) — the fact, stated, above
|
||||||
|
/// the Board Settings… door.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
private struct BoardGitNoRepositoryNote: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text("This board has no git history yet.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The repo-nested explanation** (06-history-undo.md ▸ Rules), worded as the design words it:
|
||||||
|
/// short prose in place of an action, never a hidden or greyed-out add-git.
|
||||||
|
private struct BoardGitNestedNote: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text("This board lives inside a repository; Lanework leaves it to that repository.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The unverifiable explanation** (06-history-undo.md ▸ Rules ▸ Detection, "Denial is not
|
||||||
|
/// absence", ruled 2026-07-31), worded as its own honest sentence rather than borrowing
|
||||||
|
/// `BoardGitNestedNote`'s — a denied ancestor check is not a found repository, and telling a user
|
||||||
|
/// their board is nested when the truth is "couldn't check" would be a lie dressed as caution.
|
||||||
|
private struct BoardGitUnverifiableNote: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text("Lanework couldn't verify whether this board sits inside a repository, so it isn't offering to add one here.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and
|
||||||
|
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
|
||||||
|
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
|
||||||
|
/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is
|
||||||
|
/// one line, shown only when it is true — and it is **the one in-context pointer to Pro**, the second
|
||||||
|
/// of the three places the app names it (12 ▸ Tier naming; the other two are `AboutBox` and the
|
||||||
|
/// Settings Pro section).
|
||||||
|
///
|
||||||
|
/// On an ordinary board nothing instantiates this type at all — the board has no Git tab to put it in
|
||||||
|
/// (`BoardInfoTab.available`), matching the card window's absent History section (12: "absent, no
|
||||||
|
/// placeholder"). Only a board that carries an inert `.git` (12 ▸ the inert posture: "any `.git` is
|
||||||
|
/// inert" — the free tier never reads or writes it, whether the board's own or one a lapsed
|
||||||
|
/// subscription left behind) earns this note, worded exactly as 12 rules: an honest explanation of
|
||||||
|
/// what the folder is, named exactly where the question arises, never a standing ad for Pro.
|
||||||
|
///
|
||||||
|
/// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly
|
||||||
|
/// (a fixture board with `.git` → true, without → false), and `BoardInfoView.init` is its one caller
|
||||||
|
/// in the app — the popover asks the question once at open and hands the answer down, both to this
|
||||||
|
/// tab and to `BoardInfoTab.available`.
|
||||||
|
struct BoardGitNote: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text("This board has a git history. Lanework Pro works with it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `boardRoot` carries a `.git` entry — the free tier's entire detection story, and a deliberately
|
||||||
|
/// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact
|
||||||
|
/// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder
|
||||||
|
/// watch by design (§ .git filtering — it exists to ignore git churn), so there is no reload
|
||||||
|
/// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken
|
||||||
|
/// once, at the moment the popover is built, is the honest amount of machinery for a single quiet
|
||||||
|
/// line. A `.git` added or removed while the popover happens to be open is stale until the next
|
||||||
|
/// open — a gap this note's own posture makes harmless, since it is a signpost, not a control.
|
||||||
|
static func hasGitDirectory(at boardRoot: URL) -> Bool {
|
||||||
|
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The tab
|
||||||
|
|
||||||
|
/// The Git tab's surface: whichever of the six postures this board is in, and the Board Settings…
|
||||||
|
/// door where it applies.
|
||||||
|
struct BoardGitTabView: View {
|
||||||
|
|
||||||
|
let store: BoardStore
|
||||||
|
|
||||||
|
/// The tier and the git state this board's **session** composed with, handed down from
|
||||||
|
/// `BoardInfoView` rather than re-derived — 12-editions.md ▸ The entitlement ("a lapse never
|
||||||
|
/// interrupts an open session"). `git` is `@Observable`, so a branch switch or a pause landing
|
||||||
|
/// while the tab is open repaints it.
|
||||||
|
let tier: Tier
|
||||||
|
let git: HistoryStore?
|
||||||
|
|
||||||
|
/// The window's settings sheet, so this tab can carry the **Board Settings…** row that opens it.
|
||||||
|
/// `nil` where there is no window to present a sheet on, which reads as a tab with no door.
|
||||||
|
let settings: BoardSettingsPresentation?
|
||||||
|
|
||||||
|
/// The free tier's one question about the board's folder, asked once in `BoardInfoView.init` and
|
||||||
|
/// passed down — the same answer the tab strip's membership was computed from, so the tab and its
|
||||||
|
/// content can never disagree about which posture this board is in.
|
||||||
|
let hasGitDirectory: Bool
|
||||||
|
|
||||||
|
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s and
|
||||||
|
/// `BoardThemeTabView`'s own parameter — the tab pads by this amount instead of restating the
|
||||||
|
/// derivation.
|
||||||
|
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)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tab's content, posture by posture — see `BoardGitSection`. No section header and no
|
||||||
|
/// dividers: the tab label names this surface, and the tabs already sit under the popover
|
||||||
|
/// header's rule.
|
||||||
|
@ViewBuilder
|
||||||
|
private var posture: some View {
|
||||||
|
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
|
||||||
|
case .absent:
|
||||||
|
// Unreachable, and kept honest rather than fatal: `BoardInfoTab.available` drops the Git
|
||||||
|
// tab from the strip on exactly this posture, so nothing ever selects a tab that would
|
||||||
|
// land here. Written as `EmptyView` because that is what the case *means* — if the
|
||||||
|
// membership rule ever loosened, the free tier's ordinary board would render nothing,
|
||||||
|
// which is still the design's answer for it (12: "absent, no placeholder").
|
||||||
|
EmptyView()
|
||||||
|
|
||||||
|
case .proPointer:
|
||||||
|
BoardGitNote()
|
||||||
|
|
||||||
|
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.
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
BoardGitNoRepositoryNote()
|
||||||
|
boardSettingsRow
|
||||||
|
}
|
||||||
|
|
||||||
|
case .repoNested:
|
||||||
|
BoardGitNestedNote()
|
||||||
|
|
||||||
|
case .unverifiable:
|
||||||
|
BoardGitUnverifiableNote()
|
||||||
|
|
||||||
|
case .branch:
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if let git {
|
||||||
|
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
|
||||||
|
}
|
||||||
|
boardSettingsRow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **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(
|
||||||
|
tier: tier,
|
||||||
|
mode: git?.mode ?? .none,
|
||||||
|
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
|
||||||
|
) {
|
||||||
|
Button("Board Settings…") {
|
||||||
|
dismiss()
|
||||||
|
settings.present()
|
||||||
|
}
|
||||||
|
.accessibilityHint("Opens the board settings sheet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,17 @@ import SwiftUI
|
|||||||
/// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the
|
/// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the
|
||||||
/// widget in the window's titlebar that opens it.
|
/// widget in the window's titlebar that opens it.
|
||||||
///
|
///
|
||||||
/// **Restructuring in progress (2026-08-07): the popover is going tabbed.** The symbol/name header
|
/// **Tabbed since 2026-08-07 — the restructure is complete, all three tab sessions settled.** The
|
||||||
/// stays at the top; below it sit three tabs — **Info**, **Theme**, **Git** — each a settings
|
/// symbol/name header stays at the top; below it sit the tabs — **Info**, **Theme**, **Git** — each
|
||||||
/// surface for one aspect of board configuration, each settled in its own dedicated design session.
|
/// the settings surface for one aspect of board configuration, each settled in its own dedicated
|
||||||
/// **Info and Theme are settled** (both 2026-08-07 — `BoardInfoTabView`, the metrics dossier;
|
/// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color /
|
||||||
/// `BoardThemeTabView`, the Solid color / Pattern picker — the Background tab's original name, before
|
/// Pattern picker (the Background tab's original name, before the same session widened it past the
|
||||||
/// the same session widened it past the generated-only picker and folded manual styling back out to
|
/// generated-only picker and folded manual styling back out to Style… ⌥⌘S); and `BoardGitTabView`,
|
||||||
/// Style… ⌥⌘S); Git remains deliberately empty until its own session. The former body's mode-aware
|
/// which the pre-tab body's mode-aware git section rehomed into whole — postures, notes, and the
|
||||||
/// git section is unrendered for the interim but parked in this file (see the "Parked" mark below),
|
/// Board Settings… row, none of them re-ruled by the move.
|
||||||
/// because its pure seams (`BoardGitSection`, the posture notes, `BoardSettingsAvailability`'s
|
///
|
||||||
/// caller) are settled design and will rehome into the Git tab once that session rules.
|
/// **Tab membership is the git posture's** (`BoardInfoTab.available(tier:mode:hasGitDirectory:)`),
|
||||||
|
/// and **selection resets to Info on every open** — both the Git session's rulings.
|
||||||
///
|
///
|
||||||
/// ### One home, deliberately
|
/// ### One home, deliberately
|
||||||
///
|
///
|
||||||
@@ -89,7 +90,7 @@ struct BoardInfoWidget: View {
|
|||||||
|
|
||||||
@Bindable var presentation: BoardInfoPresentation
|
@Bindable var presentation: BoardInfoPresentation
|
||||||
|
|
||||||
/// The window's settings sheet, so the popover's git section can carry the **Board Settings…**
|
/// 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 —
|
/// 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,
|
/// 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.
|
/// which is the accessory-installation tests' shape and reads as a popover with no row.
|
||||||
@@ -200,7 +201,7 @@ struct BoardInfoWidget: View {
|
|||||||
/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its
|
/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its
|
||||||
/// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility
|
/// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility
|
||||||
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same
|
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same
|
||||||
/// reason `BoardGitSection.resolve` exists one level down in this file.
|
/// reason `BoardGitSection.resolve` exists over in `BoardGitTabView.swift`.
|
||||||
///
|
///
|
||||||
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
|
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
|
||||||
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
|
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
|
||||||
@@ -279,9 +280,9 @@ func boardInfoTitlebarAccessory(
|
|||||||
// MARK: - Tabs
|
// MARK: - Tabs
|
||||||
|
|
||||||
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
|
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
|
||||||
/// restructure): **Info**, **Theme**, **Git**. Info and Theme are settled
|
/// restructure — all three sessions settled): **Info** (`BoardInfoTabView`), **Theme**
|
||||||
/// (`BoardInfoTabView`, `BoardThemeTabView`); Git is a placeholder — empty on purpose — until
|
/// (`BoardThemeTabView`), **Git** (`BoardGitTabView`). The raw values are the segmented control's own
|
||||||
/// its own dedicated design session, which then only has to fill its case in.
|
/// labels, so the strip needs no separate label function.
|
||||||
enum BoardInfoTab: String, CaseIterable, Identifiable {
|
enum BoardInfoTab: String, CaseIterable, Identifiable {
|
||||||
|
|
||||||
case info = "Info"
|
case info = "Info"
|
||||||
@@ -289,6 +290,27 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
|
|||||||
case git = "Git"
|
case git = "Git"
|
||||||
|
|
||||||
var id: Self { self }
|
var id: Self { self }
|
||||||
|
|
||||||
|
/// **Which tabs this board actually offers** — membership *is* the git posture (03-board-ui.md
|
||||||
|
/// § Board popover, the Git session's ruling, 2026-08-07): the Git tab joins the strip only when
|
||||||
|
/// the git section has something true to say, and drops out exactly where `BoardGitSection`
|
||||||
|
/// resolves to `.absent` — the free tier's ordinary board, which shows Info | Theme alone.
|
||||||
|
///
|
||||||
|
/// That carries 12-editions.md's "absent, no placeholder" rule up to the tab strip: a standing
|
||||||
|
/// Git tab on every free board would be the standing ad for Pro that 12 forbids, and an empty tab
|
||||||
|
/// behind it would be the placeholder 12 forbids twice over.
|
||||||
|
///
|
||||||
|
/// **Delegated, never restated.** The posture matrix is settled, test-pinned design
|
||||||
|
/// (`BoardGitSectionTests`), and a second reading of "is there git to show here" is a reading
|
||||||
|
/// that can disagree with the tab it labels — so this asks `BoardGitSection.resolve` the same
|
||||||
|
/// question `BoardGitTabView` asks, from the same three facts.
|
||||||
|
///
|
||||||
|
/// Order is `allCases`' own — Info, then Theme, then Git — so the Git tab, where present, is
|
||||||
|
/// always last and the two standing tabs never move under a user who switched boards.
|
||||||
|
static func available(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> [BoardInfoTab] {
|
||||||
|
let posture = BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: hasGitDirectory)
|
||||||
|
return allCases.filter { $0 != .git || posture != .absent }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The popover's content
|
// MARK: - The popover's content
|
||||||
@@ -297,9 +319,9 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
|
|||||||
/// (03-board-ui.md § Board popover).
|
/// (03-board-ui.md § Board popover).
|
||||||
///
|
///
|
||||||
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
|
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
|
||||||
/// beside a card — kept through the restructure so the popover's footprint doesn't wander while Git
|
/// beside a card — kept through the restructure so the popover's footprint didn't wander while the
|
||||||
/// is still a placeholder; both tabs settled so far (Info, Theme) kept it too, so whether the
|
/// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface
|
||||||
/// tabbed surface ever wants its own width remains open, but nothing has needed one yet.
|
/// ever wants its own width remains open, but nothing has needed one yet.
|
||||||
struct BoardInfoView: View {
|
struct BoardInfoView: View {
|
||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
@@ -308,25 +330,22 @@ struct BoardInfoView: View {
|
|||||||
let git: HistoryStore?
|
let git: HistoryStore?
|
||||||
let settings: BoardSettingsPresentation?
|
let settings: BoardSettingsPresentation?
|
||||||
|
|
||||||
/// **The popover's own dismissal**, used by exactly one control: the Board Settings… row, whose
|
/// The selected tab, and **it resets to Info on every open** — a ruling, not an accident (the
|
||||||
/// job is to close this surface and open the sheet. The popover is presented by `isPresented`, so
|
/// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover
|
||||||
/// the environment action drives the same flag the widget's button does — nothing here has to be
|
/// is transient and Info is the board's face, and a remembered tab could strand selection on a
|
||||||
/// handed the widget's binding to put it down.
|
/// tab the next board's posture doesn't offer. `@State` on the popover's content, which
|
||||||
@Environment(\.dismiss) private var dismiss
|
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
|
||||||
|
|
||||||
/// The selected tab. `@State` on the popover's content, which `BoardInfoWidget` hands `.popover`
|
|
||||||
/// fresh on every open — so the selection resets to Info per open. Whether the popover should
|
|
||||||
/// instead remember its last tab is a question for the tab sessions, once the tabs have content
|
|
||||||
/// worth returning to.
|
|
||||||
@State private var tab: BoardInfoTab = .info
|
@State private var tab: BoardInfoTab = .info
|
||||||
|
|
||||||
/// Whether this board carries a `.git` — checked once, off disk, when the view is built (which
|
/// Whether this board carries a `.git` — checked once, off disk, when the view is built (which
|
||||||
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
|
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
|
||||||
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
|
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
|
||||||
///
|
///
|
||||||
/// **The free tier's input only.** Under Pro the section reads the session's detected mode
|
/// **The free tier's input only.** Under Pro the Git tab reads the session's detected mode
|
||||||
/// instead — a fact settled at open, which is where 06-history-undo.md puts detection — and this
|
/// instead — a fact settled at open, which is where 06-history-undo.md puts detection — and this
|
||||||
/// stays what it always was: the one quiet question the free tier asks of a board's folder.
|
/// stays what it always was: the one quiet question the free tier asks of a board's folder. Since
|
||||||
|
/// the tab restructure it answers for the tab *strip* as well as for the tab's content
|
||||||
|
/// (`tabs` below), which is why it is read once here and handed down rather than asked twice.
|
||||||
private let hasGitDirectory: Bool
|
private let hasGitDirectory: Bool
|
||||||
|
|
||||||
/// The style editor brings its own padding, so the sections around it carry the same number by
|
/// The style editor brings its own padding, so the sections around it carry the same number by
|
||||||
@@ -337,6 +356,21 @@ struct BoardInfoView: View {
|
|||||||
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The tab strip's membership** (`BoardInfoTab.available`), read inside `body` so the mode it
|
||||||
|
/// delegates to is live — `HistoryStore` is `@Observable`, and add-git flipping a board's mode
|
||||||
|
/// must not leave a stale strip behind it.
|
||||||
|
///
|
||||||
|
/// **Stable across an open, in practice and by construction.** `tier` is the session's, fixed at
|
||||||
|
/// the widget's birth (12-editions.md ▸ The entitlement: "a lapse never interrupts an open
|
||||||
|
/// session"), and `hasGitDirectory` is one `init`-time read; the mode is the only live input, and
|
||||||
|
/// `BoardGitSection.resolve` answers `.absent` only under the free tier, where it never consults
|
||||||
|
/// the mode at all. So no live change can move the Git tab in or out from under the selection.
|
||||||
|
/// Add-git — the one gesture that flips a mode — runs from the settings sheet anyway, which the
|
||||||
|
/// Board Settings… row dismisses this popover to reach.
|
||||||
|
private var tabs: [BoardInfoTab] {
|
||||||
|
BoardInfoTab.available(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
store: BoardStore,
|
store: BoardStore,
|
||||||
recents: StyleRecents,
|
recents: StyleRecents,
|
||||||
@@ -398,9 +432,10 @@ struct BoardInfoView: View {
|
|||||||
// The tab bar: a segmented control rather than a `TabView`, because the popover is a
|
// The tab bar: a segmented control rather than a `TabView`, because the popover is a
|
||||||
// compact settings surface and the segmented idiom is the macOS shape for switching
|
// compact settings surface and the segmented idiom is the macOS shape for switching
|
||||||
// between a handful of peer panes inside one. The label is hidden visually but stays
|
// between a handful of peer panes inside one. The label is hidden visually but stays
|
||||||
// the control's accessibility name.
|
// the control's accessibility name. It iterates `tabs`, not `allCases` — membership is
|
||||||
|
// this board's git posture (`BoardInfoTab.available`).
|
||||||
Picker("Board configuration", selection: $tab) {
|
Picker("Board configuration", selection: $tab) {
|
||||||
ForEach(BoardInfoTab.allCases) { tab in
|
ForEach(tabs) { tab in
|
||||||
Text(tab.rawValue)
|
Text(tab.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,125 +444,29 @@ struct BoardInfoView: View {
|
|||||||
.padding(.horizontal, inset)
|
.padding(.horizontal, inset)
|
||||||
.padding(.top, inset)
|
.padding(.top, inset)
|
||||||
|
|
||||||
// The selected tab's surface. Info and Theme are settled (2026-08-07 —
|
// The selected tab's surface — all three settled 2026-08-07, each in its own dedicated
|
||||||
// `BoardInfoTabView`, `BoardThemeTabView`); Git stays a placeholder until its own
|
// session and its own file. Each pads itself by `inset`, so the switch adds nothing.
|
||||||
// session, holding a fixed height so an empty tab reads as a surface awaiting content
|
|
||||||
// rather than a collapsed sliver — `Color.clear`, because an `EmptyView` inside a frame
|
|
||||||
// renders nothing at all.
|
|
||||||
switch tab {
|
switch tab {
|
||||||
case .info:
|
case .info:
|
||||||
BoardInfoTabView(store: store, inset: inset)
|
BoardInfoTabView(store: store, inset: inset)
|
||||||
case .theme:
|
case .theme:
|
||||||
BoardThemeTabView(store: store, inset: inset)
|
BoardThemeTabView(store: store, inset: inset)
|
||||||
case .git:
|
case .git:
|
||||||
Color.clear.frame(height: 120)
|
BoardGitTabView(
|
||||||
|
store: store,
|
||||||
|
tier: tier,
|
||||||
|
git: git,
|
||||||
|
settings: settings,
|
||||||
|
hasGitDirectory: hasGitDirectory,
|
||||||
|
inset: inset
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The style editor's popover width, taken from the editor rather than restated: the embed
|
// The style editor's popover width, taken from the editor rather than restated — the number
|
||||||
// below must lay out here exactly as it does at its other two anchors, and that number is
|
// that keeps a compact settings popover narrow enough to sit beside a card. It is
|
||||||
// now font-derived.
|
// font-derived, so the whole surface scales with the grids inside it (10-accessibility.md).
|
||||||
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
|
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Parked pending the Git tab session (2026-08-07)
|
|
||||||
//
|
|
||||||
// Nothing below this mark renders today. The style-editor embed that once lived here briefly
|
|
||||||
// rehomed to `BoardThemeTabView` and has since moved back out of the popover entirely — manual
|
|
||||||
// board styling is reachable through Style… ⌥⌘S, and the Theme tab's Solid color / Pattern picker
|
|
||||||
// covers the same ground its "Board" background grid did. What is left below is the git section —
|
|
||||||
// postures, notes, and the Board Settings… row — waiting for the Git tab's own session. Parked
|
|
||||||
// rather than deleted because every seam it hangs on is settled, test-pinned design
|
|
||||||
// (`BoardGitSectionTests`, `BoardSettingsAvailabilityTests`), and the tab sessions rehome
|
|
||||||
// surfaces, not rulings.
|
|
||||||
|
|
||||||
/// The popover's closing section, whichever of the six postures this board is in — see
|
|
||||||
/// `BoardGitSection`.
|
|
||||||
@ViewBuilder
|
|
||||||
private var gitSection: some View {
|
|
||||||
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
|
|
||||||
case .absent:
|
|
||||||
EmptyView()
|
|
||||||
|
|
||||||
case .proPointer:
|
|
||||||
Divider()
|
|
||||||
BoardGitNote()
|
|
||||||
.padding(inset)
|
|
||||||
|
|
||||||
case .noRepository:
|
|
||||||
// Nothing daily to show on a board with no repository — so the section is the door and
|
|
||||||
// its header. 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.
|
|
||||||
Divider()
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Git")
|
|
||||||
boardSettingsRow
|
|
||||||
}
|
|
||||||
.padding(inset)
|
|
||||||
|
|
||||||
case .repoNested:
|
|
||||||
Divider()
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Git")
|
|
||||||
BoardGitNestedNote()
|
|
||||||
}
|
|
||||||
.padding(inset)
|
|
||||||
|
|
||||||
case .unverifiable:
|
|
||||||
Divider()
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Git")
|
|
||||||
BoardGitUnverifiableNote()
|
|
||||||
}
|
|
||||||
.padding(inset)
|
|
||||||
|
|
||||||
case .branch:
|
|
||||||
Divider()
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
sectionHeader("Git")
|
|
||||||
if let git {
|
|
||||||
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
|
|
||||||
}
|
|
||||||
boardSettingsRow
|
|
||||||
}
|
|
||||||
.padding(inset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **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`): a popover section
|
|
||||||
/// 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(
|
|
||||||
tier: tier,
|
|
||||||
mode: git?.mode ?? .none,
|
|
||||||
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
|
|
||||||
) {
|
|
||||||
Button("Board Settings…") {
|
|
||||||
dismiss()
|
|
||||||
settings.present()
|
|
||||||
}
|
|
||||||
.accessibilityHint("Opens the board settings sheet")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The section titles, matching the style editor's own headers so the popover reads as one
|
|
||||||
/// surface rather than borrowed ones.
|
|
||||||
private func sectionHeader(_ title: String) -> some View {
|
|
||||||
Text(title)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Rename
|
// MARK: - Rename
|
||||||
@@ -605,139 +544,3 @@ private struct BoardRenameField: View {
|
|||||||
store.rootURL.deletingPathExtension().lastPathComponent
|
store.rootURL.deletingPathExtension().lastPathComponent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Git
|
|
||||||
|
|
||||||
/// **What the popover's git slot is, for one board** (03-board-ui.md ▸ Board popover;
|
|
||||||
/// 06-history-undo.md ▸ Rules; 12-editions.md ▸ The free tier and `.git`) — a pure function of two
|
|
||||||
/// facts, so the posture matrix is provable without a popover on screen.
|
|
||||||
///
|
|
||||||
/// The free tier's two cases are settled 2026-07-27 and unchanged by this card: absent on an
|
|
||||||
/// ordinary board, a one-line Pro pointer on a board carrying an inert `.git`. The Pro cases are the
|
|
||||||
/// mode, one to one — and the mode-`none` and repo-nested pair is where the design is most
|
|
||||||
/// insistent: a repo-nested board gets **prose, not a disabled button**. "The option is absent
|
|
||||||
/// because it *can't* apply, and the UI should teach that rather than look broken" (06 ▸ Rules).
|
|
||||||
/// `unverifiable` (the git-detection axis) joins as a fourth Pro case, structurally identical to
|
|
||||||
/// `repoNested` but worded as its own honest prose — a denial is not a nesting.
|
|
||||||
///
|
|
||||||
/// **The 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.
|
|
||||||
enum BoardGitSection: Equatable, CaseIterable {
|
|
||||||
|
|
||||||
/// Nothing at all — the free tier's ordinary board, where "the popover is rename + style,
|
|
||||||
/// complete in itself".
|
|
||||||
case absent
|
|
||||||
|
|
||||||
/// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer
|
|
||||||
/// to Pro (12 ▸ Tier naming).
|
|
||||||
case proPointer
|
|
||||||
|
|
||||||
/// Pro, mode `none`: a board that could have a history and has none. There is no daily surface
|
|
||||||
/// for that — the section is the header and the Board Settings… row, where add-git now lives
|
|
||||||
/// (03 ▸ Board settings sheet).
|
|
||||||
case noRepository
|
|
||||||
|
|
||||||
/// Pro, repo-nested: the honest explanation, no action — and no Board Settings… row either,
|
|
||||||
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
|
|
||||||
case repoNested
|
|
||||||
|
|
||||||
/// Pro, unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository
|
|
||||||
/// (06 ▸ Rules ▸ Detection, "Denial is not absence"). Structurally identical to `.repoNested`
|
|
||||||
/// (no action, no Board Settings… row, `BoardSettingsAvailability` false), but its own case so
|
|
||||||
/// the view renders its own honest prose rather than the nested sentence — "unverifiable" is not
|
|
||||||
/// "nested".
|
|
||||||
case unverifiable
|
|
||||||
|
|
||||||
/// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state
|
|
||||||
/// explanation when the surface is held, and the Board Settings… row. The remote half —
|
|
||||||
/// tracking, Pull/Push, the status badges — is 07-sync-collab.md's own card and joins this same
|
|
||||||
/// posture.
|
|
||||||
case branch
|
|
||||||
|
|
||||||
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
|
|
||||||
switch tier {
|
|
||||||
case .free:
|
|
||||||
// Detection never runs under the free tier, so the mode is not consulted here — the one
|
|
||||||
// question asked is whether the folder carries a `.git`, which is what the pointer is
|
|
||||||
// about (12: "any `.git` is inert … a stray like any other, preserved verbatim").
|
|
||||||
return hasGitDirectory ? .proPointer : .absent
|
|
||||||
case .pro:
|
|
||||||
switch mode {
|
|
||||||
case .none: return .noRepository
|
|
||||||
case .git: return .branch
|
|
||||||
case .repoNested: return .repoNested
|
|
||||||
case .unverifiable: return .unverifiable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **The repo-nested explanation** (06-history-undo.md ▸ Rules), worded as the design words it:
|
|
||||||
/// short prose in place of an action, never a hidden or greyed-out add-git.
|
|
||||||
private struct BoardGitNestedNote: View {
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text("This board lives inside a repository; Lanework leaves it to that repository.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **The unverifiable explanation** (06-history-undo.md ▸ Rules ▸ Detection, "Denial is not
|
|
||||||
/// absence", ruled 2026-07-31), worded as its own honest sentence rather than borrowing
|
|
||||||
/// `BoardGitNestedNote`'s — a denied ancestor check is not a found repository, and telling a user
|
|
||||||
/// their board is nested when the truth is "couldn't check" would be a lie dressed as caution.
|
|
||||||
private struct BoardGitUnverifiableNote: View {
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text("Lanework couldn't verify whether this board sits inside a repository, so it isn't offering to add one here.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and
|
|
||||||
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
|
|
||||||
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
|
|
||||||
/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is
|
|
||||||
/// one line, shown only when it is true — and it is **the one in-context pointer to Pro**, the second
|
|
||||||
/// of the three places the app names it (12 ▸ Tier naming; the other two are `AboutBox` and the
|
|
||||||
/// Settings Pro section).
|
|
||||||
///
|
|
||||||
/// On an ordinary board `BoardInfoView` never instantiates this type at all — the section is
|
|
||||||
/// *absent*, matching the card window's absent History section (12: "absent, no placeholder"). Only
|
|
||||||
/// a board that carries an inert `.git` (12 ▸ the inert posture: "any `.git` is inert" — the free
|
|
||||||
/// tier never reads or writes it, whether the board's own or one a lapsed subscription left behind)
|
|
||||||
/// earns this note, worded exactly as 12 rules: an honest explanation of what the folder is, named
|
|
||||||
/// exactly where the question arises, never a standing ad for Pro.
|
|
||||||
///
|
|
||||||
/// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly
|
|
||||||
/// (a fixture board with `.git` → true, without → false), which needs it visible past this file even
|
|
||||||
/// though nothing outside `BoardInfoPopover.swift` calls it in the app itself.
|
|
||||||
struct BoardGitNote: View {
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text("This board has a git history. Lanework Pro works with it.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `boardRoot` carries a `.git` entry — the free tier's entire detection story, and a deliberately
|
|
||||||
/// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact
|
|
||||||
/// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder
|
|
||||||
/// watch by design (§ .git filtering — it exists to ignore git churn), so there is no reload
|
|
||||||
/// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken
|
|
||||||
/// once, at the moment the popover is built, is the honest amount of machinery for a single quiet
|
|
||||||
/// line. A `.git` added or removed while the popover happens to be open is stale until the next
|
|
||||||
/// open — a gap this note's own posture makes harmless, since it is a signpost, not a control.
|
|
||||||
static func hasGitDirectory(at boardRoot: URL) -> Bool {
|
|
||||||
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import Testing
|
|||||||
/// inert `.git`. The seam
|
/// inert `.git`. The seam
|
||||||
/// that decides *whether* to show it — `BoardGitNote.hasGitDirectory(at:)` — is a pure, read-only
|
/// that decides *whether* to show it — `BoardGitNote.hasGitDirectory(at:)` — is a pure, read-only
|
||||||
/// `FileManager` check, pinned here against real bytes on disk. Everything else about the note (its
|
/// `FileManager` check, pinned here against real bytes on disk. Everything else about the note (its
|
||||||
/// wording, its placement in `BoardInfoView`) is SwiftUI rendering and deliberately untested.
|
/// wording, its placement in `BoardGitTabView`) is SwiftUI rendering and deliberately untested.
|
||||||
|
///
|
||||||
|
/// Since the 2026-08-07 tab restructure this same answer decides one thing more: whether the popover
|
||||||
|
/// carries a Git tab at all on a free board (`BoardInfoTabAvailabilityTests` below).
|
||||||
struct BoardInfoPopoverTests {
|
struct BoardInfoPopoverTests {
|
||||||
|
|
||||||
@Test("A board with a .git at its root reports true")
|
@Test("A board with a .git at its root reports true")
|
||||||
@@ -108,6 +111,67 @@ struct BoardGitSectionTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The tab strip's membership** (03-board-ui.md ▸ Board popover, the tabbed-popover paragraph —
|
||||||
|
/// the Git session's ruling, 2026-08-07): the Git tab joins the strip only where the git section has
|
||||||
|
/// something true to say, so a free-tier board with no `.git` shows Info | Theme alone.
|
||||||
|
///
|
||||||
|
/// `BoardInfoTab.available` is pinned here for the reason `BoardGitSection.resolve` is pinned above:
|
||||||
|
/// it is the whole decision, pure in its three inputs, and the segmented `Picker` it feeds is SwiftUI
|
||||||
|
/// and stays untested. What these tests are really about is that membership **is** the posture rather
|
||||||
|
/// than a second reading of it — the last one walks the entire input matrix and asserts the tab's
|
||||||
|
/// presence against `resolve` itself, so the strip cannot drift from the surface it labels.
|
||||||
|
@Suite("Board popover ▸ the tab strip's membership")
|
||||||
|
struct BoardInfoTabAvailabilityTests {
|
||||||
|
|
||||||
|
@Test("A free board with no .git shows Info and Theme alone — never a standing Git tab")
|
||||||
|
func theFreeTierDropsTheGitTab() {
|
||||||
|
// 12-editions.md's "absent, no placeholder" carried up to the tab strip: a Git tab on every
|
||||||
|
// free board would be the standing ad for Pro that 12 forbids.
|
||||||
|
#expect(BoardInfoTab.available(tier: .free, mode: .none, hasGitDirectory: false) == [.info, .theme])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A free board carrying an inert .git earns the tab — it has the Pro pointer to say")
|
||||||
|
func anInertGitEarnsTheTab() {
|
||||||
|
#expect(BoardInfoTab.available(tier: .free, mode: .none, hasGitDirectory: true) == [.info, .theme, .git])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Pro shows the Git tab under every mode — each of the four postures is a tab surface")
|
||||||
|
func proAlwaysShowsTheTab() {
|
||||||
|
for mode in BoardGitMode.allCases {
|
||||||
|
for hasGit in [true, false] {
|
||||||
|
#expect(
|
||||||
|
BoardInfoTab.available(tier: .pro, mode: mode, hasGitDirectory: hasGit)
|
||||||
|
== [.info, .theme, .git],
|
||||||
|
"Pro, mode \(mode): the tab is the posture's home, and Git is always last"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The tab is present exactly where the git section is not absent, across the whole matrix")
|
||||||
|
func membershipDelegatesToThePosture() {
|
||||||
|
for tier in Tier.allCases {
|
||||||
|
for mode in BoardGitMode.allCases {
|
||||||
|
for hasGit in [true, false] {
|
||||||
|
let tabs = BoardInfoTab.available(tier: tier, mode: mode, hasGitDirectory: hasGit)
|
||||||
|
let isAbsent =
|
||||||
|
BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: hasGit) == .absent
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
tabs.contains(.git) == !isAbsent,
|
||||||
|
"\(tier)/\(mode)/git=\(hasGit): membership is the posture, not a second reading of it"
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
Array(tabs.prefix(2)) == [.info, .theme],
|
||||||
|
"Info and Theme stand on every board, in that order — Info is the board's face"
|
||||||
|
)
|
||||||
|
#expect(tabs.count == Set(tabs).count, "no tab appears twice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// **The window-title widget's two strings** (03-board-ui.md ▸ Board popover, the card that widened
|
/// **The window-title widget's two strings** (03-board-ui.md ▸ Board popover, the card that widened
|
||||||
/// the widget from a chevron to the whole board-name area): `BoardInfoTitlebarSummary` is the pure
|
/// the widget from a chevron to the whole board-name area): `BoardInfoTitlebarSummary` is the pure
|
||||||
/// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its
|
/// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its
|
||||||
|
|||||||
Reference in New Issue
Block a user