Build the board popover — title widget, rename, styling, git slot

The window-title widget arrives as a leading titlebar accessory — a
quiet chevron on board windows only, installed and removed by the
window controller's own attach lifecycle — anchoring the one
board-level surface as a transient popover (the board window
deliberately grows no toolbar item for it). Inside: board rename
editing frontmatter title only (the folder is never renamed; an empty
commit removes the key and the window title falls back to the folder
name), the embedded shared style editor permanently targeting the
board, and the labeled Git section that this milestone only reserves
— a mode-none explanation and a disabled stub where m7's add-git,
branch, remote, and authentication controls land. Cmd-I (File >
Board Info) toggles it per window through a focused scene value,
kept apart from board-scoped transient state since a titlebar
popover belongs to one window, not to the board. Escape reverts a
dirty rename field and falls through to dismiss otherwise; a foreign
rename resyncs the field only while unfocused. 12 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 15:08:06 -04:00
parent c6298c2e41
commit aa6aaf2a11
8 changed files with 745 additions and 11 deletions
+22 -5
View File
@@ -33,6 +33,12 @@ struct BoardWindowHost: View {
/// alive for exactly as long as this window exists.
@State private var windowController = HostedWindowController()
/// This window's board popover, open or not (03-board-ui.md § Board popover). `@State` for the
/// window controller's reason one per window, living exactly as long as the window which is
/// also what makes I mean "the board in front" rather than "some board": the flag reaches the
/// menu item through the focus system, like the store.
@State private var boardInfo = BoardInfoPresentation()
@State private var phase: Phase = .opening
private enum Phase {
@@ -79,8 +85,10 @@ struct BoardWindowHost: View {
}
)
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`).
// "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.
.focusedSceneValue(\.boardStore, store)
.focusedSceneValue(\.boardInfo, boardInfo)
}
}
@@ -123,16 +131,16 @@ struct BoardWindowHost: View {
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
phase = .open(store)
configureWindow(recordID: recordID)
configureWindow(store: store, recordID: recordID)
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
// when welcome is not open, which is the ordinary case.
dismissWindow(id: WindowID.welcome)
}
/// Wires the window: the saved frame on the way in, frame changes on the way back out, and the
/// close interception that makes the flush unavoidable.
private func configureWindow(recordID: UUID) {
/// Wires the window: the saved frame on the way in, frame changes on the way back out, the
/// close interception that makes the flush unavoidable, and the title-bar widget.
private func configureWindow(store: BoardStore, recordID: UUID) {
windowController.onAttach = { window in
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
@@ -157,6 +165,15 @@ struct BoardWindowHost: View {
windowController.closeAfterFlush()
}
}
// The window-title widget (03-board-ui.md § Board popover) **board windows only**, which
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
// card windows share that machinery and have no board to describe. It goes in after the
// load rather than at attach because it carries the store; the controller installs it once,
// whichever of the two arrives second.
windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo)
)
}
// MARK: - Closing
+44 -3
View File
@@ -60,6 +60,12 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// instead of starting a second flush.
private var isFlushed = false
/// The titlebar accessory this window shows, once something has given it one today the board
/// popover's window-title widget (03-board-ui.md § Board popover), and only on board windows.
/// `nil` on welcome, the bootstrap and card windows, which is why it is a slot rather than a
/// constructor argument.
private var titlebarAccessory: NSTitlebarAccessoryViewController?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
// MARK: Attachment
@@ -75,17 +81,52 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
window.delegate = self
}
onAttach?(window)
// After `onAttach`, so placement has already happened: an accessory handed over before the
// window existed is installed here instead, and one handed over later installs immediately.
addTitlebarAccessoryIfPossible()
}
/// Puts the previous delegate back. Called when the hosting view goes away; a no-op if something
/// else has since taken the delegate, because stomping a third party's would be the bug this
/// whole file exists to avoid.
/// Puts the previous delegate back, and takes the titlebar accessory back out. Called when the
/// hosting view goes away; the delegate half is a no-op if something else has since taken the
/// delegate, because stomping a third party's would be the bug this whole file exists to avoid.
func detach() {
removeTitlebarAccessory()
titlebarAccessory = nil
guard let window, window.delegate === self else { return }
window.delegate = previousDelegate
self.window = nil
}
// MARK: Titlebar accessory
/// Gives this window a titlebar accessory **once**, whatever the caller does.
///
/// The guard is the whole of the install-once rule: AppKit keeps accessories in an array and
/// would happily hold two identical widgets, and a board window's host may configure itself more
/// than once (the load returns, the window attaches, SwiftUI re-evaluates). Installing before
/// the window exists is legal the accessory is held and goes in at `attach`.
func installTitlebarAccessory(_ accessory: NSTitlebarAccessoryViewController) {
guard titlebarAccessory == nil else { return }
titlebarAccessory = accessory
addTitlebarAccessoryIfPossible()
}
private func addTitlebarAccessoryIfPossible() {
guard let window, let accessory = titlebarAccessory,
!window.titlebarAccessoryViewControllers.contains(where: { $0 === accessory })
else { return }
window.addTitlebarAccessoryViewController(accessory)
}
/// Removes ours and only ours, by identity: the index is looked up rather than assumed, because
/// nothing promises this app owns the only accessory a window carries.
private func removeTitlebarAccessory() {
guard let window, let accessory = titlebarAccessory,
let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === accessory })
else { return }
window.removeTitlebarAccessoryViewController(at: index)
}
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
/// standard path runs SwiftUI's own delegate gets its callbacks, tabbing behaves with the
/// flag telling our own `windowShouldClose` to stand aside.
+7 -2
View File
@@ -108,8 +108,9 @@ struct KanbanApp: App {
@CommandsBuilder
private var menuCommands: some Commands {
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board,
// still owed), Open. The creation pair validates against the frontmost board through the
// focus system, so both are simply absent-of-effect when no board is in front.
// still owed), Open, (Open Recent, still owed), Board Info. The creation pair and Board
// Info all validate against the frontmost board through the focus system, so each is simply
// absent-of-effect when no board is in front.
CommandGroup(after: .newItem) {
BoardCreationCommands()
@@ -119,6 +120,10 @@ struct KanbanApp: App {
appModel.presentOpenPanel()
}
.keyboardShortcut("o", modifiers: .command)
Divider()
BoardInfoCommand()
}
// The Board menu (11-command-nexus.md), in its inventoried order Rename, then Style,
+43
View File
@@ -979,6 +979,49 @@ public final class BoardStore {
return nil
}
// MARK: - Board rename
/// Writes the board's own `title` the board popover's rename field (03-board-ui.md § Board
/// popover), and the one rename in the app with no item to aim at.
///
/// **It edits frontmatter, never the folder**: "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" (§ Board popover, 01-storage-format.md § Board naming). The app's display name and
/// the Finder document name may therefore diverge, which is accepted rather than reconciled.
///
/// The three commit rules are `commitRename`'s, deliberately identical one rename vocabulary
/// whatever level it is aimed at:
///
/// - **Trimmed**, so a title of three spaces is a slip rather than a name.
/// - **An empty commit removes the key.** A board with no `title` falls back to its *folder
/// name* (§ Board naming) never the "Untitled" placeholder cards and lanes show, and never
/// `title: ""`, which would be a real if blank title with nothing to fall back to.
/// - **An unchanged title writes nothing**, so a popover opened and dismissed with Return
/// neither stamps `modified` nor mints a commit.
///
/// There is no vanished-target guard, because a board cannot tombstone itself out of its own
/// window (01-storage-format.md § Deletion): the only way this target goes away is the root
/// itself vanishing, which is the read-only lock's story, and `performWrite` refuses under it
/// before anything touches disk.
public func renameBoard(_ title: String?) {
let typed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let newTitle: String? = typed.isEmpty ? nil : typed
guard newTitle != snapshot.title.value else { return }
let folder = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
// refusal names the board by the title it still has (see `WriteOperation.rename`).
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newTitle {
document.set(FrontmatterKeys.title, to: .string(newTitle))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
}
// MARK: - Lane reorder
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
+30
View File
@@ -87,6 +87,36 @@ struct BoardCreationCommands: View {
}
}
// MARK: - Board Info
/// File Board Info (I) the board popover's keyboard path (11-command-nexus.md; class **C**,
/// whose "keyboard path is reachability (Board Info I + Tab-reachable controls), not bindings").
///
/// **It toggles**, because the popover's other entry point is a disclosure widget and a shortcut
/// that could only ever open would leave the surface with no keyboard way out.
///
/// Two focused values, both required: the store is what makes this a *board window* item (the
/// scope 11 gives the row), and the presentation is the window's own popover flag see
/// `BoardInfoPresentation` for why the flag is per window rather than per board.
///
/// **Validation is scope and nothing else.** Neither the read-only lock nor the focused-editor rule
/// closes it, unlike every mutating item above: the popover is *configuration* (04-interactions.md
/// The map's carve-out), a locked board is exactly when a user wants to read its title and
/// styling, and the controls inside disable themselves.
struct BoardInfoCommand: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var presentation
var body: some View {
Button("Board Info") {
presentation?.toggle()
}
.keyboardShortcut("i", modifiers: .command)
.disabled(store == nil || presentation == nil)
}
}
// MARK: - Rename
/// Board Rename no default chord, deliberately (11-command-nexus.md: " (cards: Return in
+282
View File
@@ -0,0 +1,282 @@
import AppKit
import SwiftUI
/// **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.
///
/// Three sections, in the design's own order: the board rename, the embedded style editor aimed at
/// the board, and the git slot. The first two are this milestone's; the third is a *reserved place*
/// see `BoardGitSlot` for what m7 grows there and why an honest placeholder beats an absent
/// section.
///
/// ### One home, deliberately
///
/// The popover has **no toolbar item** (§ Toolbar: "the window-title widget is its committed home
/// 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").
// MARK: - Presentation state
/// Whether **this window's** board popover is open.
///
/// Per window rather than per board or per app, and that is the point: I has to mean "the board in
/// front", so the flag travels with the window through the focus system (`FocusedValues.boardInfo`)
/// exactly as the store does. Two board windows each hold their own and can never toggle each
/// other's which a flag on the store could not promise the day a board is allowed two windows.
///
/// It is also why this is not in `TransientBoardState`: everything there is *board*-scoped and
/// shared with the board's card windows, and a popover living in one window's titlebar is not that.
@MainActor
@Observable
final class BoardInfoPresentation {
var isPresented = false
/// I's whole behaviour and the widget's alike. **Toggling, not opening**: a shortcut aimed at a
/// disclosure that could only ever open would leave the popover with no keyboard way out.
func toggle() {
isPresented.toggle()
}
}
/// The focused board window's popover, beside `FocusedValues.boardStore` see that key for why
/// board-window menu items reach their window this way rather than through the app model.
struct FocusedBoardInfoKey: FocusedValueKey {
typealias Value = BoardInfoPresentation
}
extension FocusedValues {
var boardInfo: BoardInfoPresentation? {
get { self[FocusedBoardInfoKey.self] }
set { self[FocusedBoardInfoKey.self] = newValue }
}
}
// MARK: - The window-title widget
/// The titlebar widget: a quiet disclosure chevron whose one job is this popover.
///
/// **The popover is anchored to the widget itself** it hangs from the chevron rather than from
/// the window or the board which is what makes the affordance and the surface read as one thing.
/// A `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a
/// click outside dismisses it), and because the content is SwiftUI either way; the AppKit half of
/// this is only the *placement* (`boardInfoTitlebarAccessory`).
struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
@Bindable var presentation: BoardInfoPresentation
var body: some View {
Button {
presentation.toggle()
} label: {
Image(systemName: "chevron.down")
.imageScale(.small)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
// Sized like a titlebar control rather than by its glyph: the hit target has to be
// clickable at titlebar scale, where the chevron alone is a few points across.
.frame(width: 20, height: 18)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Board Info")
.accessibilityLabel("Board Info")
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents)
}
}
}
/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
/// an `NSTitlebarAccessoryViewController` hosting the button, laid out `.leading` so it sits in the
/// title bar beside the window title rather than in the window's content.
///
/// `HostedWindowController.installTitlebarAccessory` owns the rest of the lifecycle one per
/// 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
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(store: store, recents: recents, presentation: presentation)
)
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
// as zero would be an invisible, unclickable widget.
hosting.sizingOptions = [.intrinsicContentSize]
hosting.frame = NSRect(x: 0, y: 0, width: 20, height: 18)
let controller = NSTitlebarAccessoryViewController()
controller.view = hosting
controller.layoutAttribute = .leading
return controller
}
// MARK: - The popover's content
/// The three sections, one view (03-board-ui.md § Board popover).
///
/// Width is the style editor's 268 points, the number that keeps the Style popover narrow enough
/// to sit beside a card so the embedded editor lays out here exactly as it does at its other two
/// anchors rather than being stretched by a container with its own opinion.
struct BoardInfoView: View {
let store: BoardStore
let recents: StyleRecents
/// The style editor brings its own padding, so the sections around it carry the same number by
/// hand instead of an outer padding that would double up on it.
private let inset: CGFloat = 14
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Title")
BoardRenameField(store: store)
}
.padding(inset)
Divider()
VStack(alignment: .leading, spacing: 0) {
sectionHeader("Styling")
.padding(.horizontal, inset)
.padding(.top, inset)
// Always the board, whatever is selected. The S anchor is the selection-aware one
// ("nothing selected = the board"); this embed is the surface that exists *because*
// the board is a style target, so it can have no other target (§ Styling
// Controls: "the board popover's target is the board itself").
StyleEditorView(store: store, recents: recents, target: .board)
}
Divider()
VStack(alignment: .leading, spacing: 8) {
sectionHeader("Git")
BoardGitSlot()
}
.padding(inset)
}
.frame(width: 268)
}
/// The section titles, matching the style editor's own headers so the popover reads as one
/// surface rather than three borrowed ones.
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.subheadline.weight(.semibold))
}
}
// MARK: - Rename
/// The board rename field (03-board-ui.md § Board popover; 01-storage-format.md § Board naming).
///
/// The exits are the inline editors' Return and focus loss commit, Escape abandons but the
/// **placeholder is the whole of the board-naming rule made visible**: an empty field shows the
/// folder name, because that is what the window title will say, and committing empty is how a user
/// asks for exactly that. "Untitled" appears nowhere; boards do not have it.
///
/// **It is not born focused**, unlike the three inline editors. Those are each opened by a gesture
/// that means "edit this now"; this one is one control among several on a configuration surface,
/// where the keyboard path is Tab-reachability rather than a caret waiting in the first field
/// (11-command-nexus.md's class **C**).
///
/// Every handler is idempotent, for `InlineTitleField`'s reason: the exits overlap by construction
/// Return commits and then something takes focus, which fires the focus-loss commit an instant
/// later and `BoardStore.renameBoard` skips an unchanged title, so the second call writes nothing.
private struct BoardRenameField: View {
let store: BoardStore
@State private var draft = ""
@FocusState private var isFocused: Bool
var body: some View {
TextField(fallbackName, text: $draft)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($isFocused)
.onSubmit { store.renameBoard(draft) }
// **Escape steps outward one layer per press** (04-interactions.md Grammar): a dirty
// field abandons its edit and keeps the popover open, and an unedited one lets the press
// through to the popover's own dismissal. Reverting first is also what makes a dismissal
// safe on the paths where the press never reaches here the focus-loss commit that
// follows sees a draft equal to what is on disk and writes nothing.
.onKeyPress(.escape) {
guard draft != committed else { return .ignored }
draft = committed
return .handled
}
.onChange(of: isFocused) { _, focused in
guard !focused else { return }
store.renameBoard(draft)
}
.onAppear { draft = committed }
// A foreign rename an agent, a hand edit, a sync landing behind an open popover
// updates the field, but never under the user's fingers: a draft being typed is the
// user's, and the reload is not an edit to it (02-architecture.md § Live-reload
// resilience, the same courtesy the inline editors get by tracking their UUID).
.onChange(of: committed) { _, title in
guard !isFocused else { return }
draft = title
}
// The popover can be dismissed without the field ever reporting focus loss, and a
// dismissal is a commit like any other click-away. Idempotent with the handler above.
.onDisappear { store.renameBoard(draft) }
// The read-only lock disables every mutating surface (02-architecture.md § The lock's
// scope) and the popover *stays open* under it, which is the style popover's settled
// precedent: the lock is a condition the banner is already explaining, not a reason to
// yank a surface away. `acceptsBoardMutations` rather than `isReadOnly` alone so this
// field and the editor below it disable as one surface rather than in halves.
.disabled(!store.acceptsBoardMutations)
}
/// The `title` on disk, as the last reload read it "" for a board that has none.
private var committed: String { store.snapshot.title.value ?? "" }
/// The folder name, sans extension: what the window title shows when `title` is absent
/// (01-storage-format.md § Board naming), and therefore the honest thing for an empty field to
/// promise. Read off `rootURL` rather than `snapshot.rootURL` so a Finder rename absorbed
/// mid-session shows here immediately.
private var fallbackName: String {
store.rootURL.deletingPathExtension().lastPathComponent
}
}
// MARK: - Git
/// The git section **a reserved slot, not a feature** (03-board-ui.md § Board popover).
///
/// Every board is mode-none today, because the app has no git at all yet: there is no detection, no
/// repository, and nothing to init. So this renders the one honest thing a mode-none board can say
/// and offers its action disabled, rather than hiding the section an absent section would teach
/// that the board popover has two parts, and the design says it has three.
///
/// m7-git: this slot grows the whole inventory in 11-command-nexus.md § Configuration controls
/// Board popover add-git on mode none, the this-board-lives-inside-a-repository explanation on
/// repo-nested boards (06-history-undo.md), branch display/switch/create, the commit-identity
/// name/email fields, add/change remote, the credential and SSH-key surfaces (machine key with Copy
/// and Verify, key import, the per-host picker, confirm-gated regeneration), the
/// Authentication-needed badge, and ahead/behind with Pull/Push and push-on-commit
/// (07-sync-collab.md). The mode-aware branching starts here, where this placeholder is.
private struct BoardGitSlot: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("This board has no repository. Version history, undo, and sync arrive with Lanework's git integration.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Add Git…") {}
.disabled(true)
}
}
}