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. /// alive for exactly as long as this window exists.
@State private var windowController = HostedWindowController() @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 @State private var phase: Phase = .opening
private enum Phase { 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(\.boardStore, store)
.focusedSceneValue(\.boardInfo, boardInfo)
} }
} }
@@ -123,16 +131,16 @@ struct BoardWindowHost: View {
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access) appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
phase = .open(store) phase = .open(store)
configureWindow(recordID: recordID) configureWindow(store: store, recordID: recordID)
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless // "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
// when welcome is not open, which is the ordinary case. // when welcome is not open, which is the ordinary case.
dismissWindow(id: WindowID.welcome) dismissWindow(id: WindowID.welcome)
} }
/// Wires the window: the saved frame on the way in, frame changes on the way back out, and the /// 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. /// close interception that makes the flush unavoidable, and the title-bar widget.
private func configureWindow(recordID: UUID) { private func configureWindow(store: BoardStore, recordID: UUID) {
windowController.onAttach = { window in windowController.onAttach = { window in
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return } guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true) window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
@@ -157,6 +165,15 @@ struct BoardWindowHost: View {
windowController.closeAfterFlush() 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 // MARK: - Closing
+44 -3
View File
@@ -60,6 +60,12 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// instead of starting a second flush. /// instead of starting a second flush.
private var isFlushed = false 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") private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
// MARK: Attachment // MARK: Attachment
@@ -75,17 +81,52 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
window.delegate = self window.delegate = self
} }
onAttach?(window) 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 /// Puts the previous delegate back, and takes the titlebar accessory back out. Called when the
/// else has since taken the delegate, because stomping a third party's would be the bug this /// hosting view goes away; the delegate half is a no-op if something else has since taken the
/// whole file exists to avoid. /// delegate, because stomping a third party's would be the bug this whole file exists to avoid.
func detach() { func detach() {
removeTitlebarAccessory()
titlebarAccessory = nil
guard let window, window.delegate === self else { return } guard let window, window.delegate === self else { return }
window.delegate = previousDelegate window.delegate = previousDelegate
self.window = nil 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 /// 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 /// standard path runs SwiftUI's own delegate gets its callbacks, tabbing behaves with the
/// flag telling our own `windowShouldClose` to stand aside. /// flag telling our own `windowShouldClose` to stand aside.
+7 -2
View File
@@ -108,8 +108,9 @@ struct KanbanApp: App {
@CommandsBuilder @CommandsBuilder
private var menuCommands: some Commands { private var menuCommands: some Commands {
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board, // 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 // still owed), Open, (Open Recent, still owed), Board Info. The creation pair and Board
// focus system, so both are simply absent-of-effect when no board is in front. // 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) { CommandGroup(after: .newItem) {
BoardCreationCommands() BoardCreationCommands()
@@ -119,6 +120,10 @@ struct KanbanApp: App {
appModel.presentOpenPanel() appModel.presentOpenPanel()
} }
.keyboardShortcut("o", modifiers: .command) .keyboardShortcut("o", modifiers: .command)
Divider()
BoardInfoCommand()
} }
// The Board menu (11-command-nexus.md), in its inventoried order Rename, then Style, // 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 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 // MARK: - Lane reorder
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes, /// 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 // MARK: - Rename
/// Board Rename no default chord, deliberately (11-command-nexus.md: " (cards: Return in /// 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)
}
}
}
+315 -1
View File
@@ -1,9 +1,11 @@
import AppKit
import Foundation import Foundation
import Testing import Testing
@testable import Kanban @testable import Kanban
/// The two inline editors' **write** paths `BoardStore.commitRename` and /// The two inline editors' **write** paths `BoardStore.commitRename` and
/// `BoardStore.commitPlaceholder` plus the lane drag's `moveLane`. /// `BoardStore.commitPlaceholder` plus the lane drag's `moveLane` and the board popover's
/// `renameBoard`, which is the same rename vocabulary aimed at the root.
/// ///
/// Like `LaneWidthWriteTests`, these drive a real store over a real temp board and then read the /// Like `LaneWidthWriteTests`, these drive a real store over a real temp board and then read the
/// **raw bytes** back rather than the app's own read path: the interesting claims are about the /// **raw bytes** back rather than the app's own read path: the interesting claims are about the
@@ -656,3 +658,315 @@ struct NewLaneWriteTests {
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
} }
// MARK: - The board rename
/// The board root's own `index.md` richer than `Item.board` because a rename has to leave all of
/// it alone: an unknown key with its inline comment, a `created` from before today, a foreign
/// `modified-by`, and the board description body.
private let richBoardIndex = """
---
schema: 1
title: Roadmap
project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
modified-by: claude
---
Board description — with *markdown*.
"""
/// A board with no `title` key at all: the folder-name fallback state a rename both starts from and
/// returns a board to (01-storage-format.md § Board naming).
private let untitledBoardIndex = """
---
schema: 1
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
---
Board description.
"""
/// Readable, uneditable, at board level: the whole-frontmatter flow mapping, which loads and renders
/// fine but has no line for the editor to key on.
private let uneditableBoardIndex = "---\n{schema: 1, title: Odd}\n---\nodd body\n"
/// A board root and one lane, so the fixture is a board the store will actually load.
@MainActor
private func makeBoardRoot(_ index: String) throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", index)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
return fixture
}
@MainActor
@Suite("BoardStore ▸ board rename")
struct BoardRenameWriteTests {
@Test("A non-empty commit writes the title, stamps modified, and touches nothing else")
func writesTheTitleAndStamps() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText("")
store.renameBoard("Q3 plan")
let after = try fixture.indexText("")
#expect(after.contains("title: Q3 plan"))
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
#expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh")
// The unknown key with its comment, the original `created`, and the description survive
// exactly, in order the round-trip contract, at board level like every other.
#expect(untouchedLines(after) == untouchedLines(before))
let model = try load(fixture)
#expect(model.title == .valid("Q3 plan"))
#expect(model.modifiedBy.isMissing)
let modified = try #require(model.modified.value)
#expect(abs(modified.timeIntervalSinceNow) < 60)
#expect(store.banners.oneShots.isEmpty)
}
@Test("The folder is never renamed — only the frontmatter moves")
func theFolderIsLeftAlone() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let name = fixture.root.lastPathComponent
let store = try BoardStore(rootURL: fixture.root)
store.renameBoard("Something else entirely")
// "The folder is never renamed by the app; the Finder document name is Finder's to change"
// (03-board-ui.md § Board popover) the display name and the document name may diverge.
#expect(fixture.root.lastPathComponent == name)
#expect(fixture.exists(""))
#expect(store.rootURL == fixture.root)
}
@Test("An empty commit removes the title key and the board falls back to its folder name")
func emptyCommitRemovesTheKey() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText("")
store.renameBoard("")
let after = try fixture.indexText("")
#expect(!after.contains("title:"))
#expect(!after.contains("title: \"\""), "the key leaves; it is never blanked")
#expect(untouchedLines(after) == untouchedLines(before), "only the title line and the stamps moved")
#expect(try load(fixture).title.isMissing)
// And the fallback is the *folder* name, never "Untitled" 01-storage-format.md's
// board-naming rule, which is also what the empty field's placeholder promises.
let reopened = try BoardStore(rootURL: fixture.root)
#expect(AppModel.displayName(of: reopened) == fixture.root.deletingPathExtension().lastPathComponent)
}
@Test("nil commits as empty — the field's absent-title state and its cleared state agree")
func nilRemovesTheKeyToo() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.renameBoard(nil)
#expect(try !fixture.indexText("").contains("title:"))
#expect(try load(fixture).title.isMissing)
}
@Test("Whitespace commits as empty, and a padded title is trimmed rather than quoted")
func whitespaceIsEmpty() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.renameBoard(" ")
#expect(try load(fixture).title.isMissing)
let padded = try makeBoardRoot(richBoardIndex)
defer { padded.tearDown() }
let other = try BoardStore(rootURL: padded.root)
other.renameBoard(" Q3 plan ")
#expect(try load(padded).title == .valid("Q3 plan"))
}
@Test("An unchanged title writes nothing at all")
func unchangedTitleIsANoOp() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let titled = try fixture.indexData("")
// A popover opened and dismissed with Return must not stamp `modified` or (on a git board)
// mint a commit `commitRename`'s rule, for its reason.
store.renameBoard("Roadmap")
#expect(try fixture.indexData("") == titled)
// Same for an untitled board committed still untitled: the key must not materialize and
// then vanish, nor the file be rewritten to say nothing new.
let untitled = try makeBoardRoot(untitledBoardIndex)
defer { untitled.tearDown() }
let untouched = try untitled.indexData("")
let second = try BoardStore(rootURL: untitled.root)
second.renameBoard("")
#expect(try untitled.indexData("") == untouched)
second.renameBoard(nil)
#expect(try untitled.indexData("") == untouched)
#expect(try untitled.entryNames("").sorted() == [Ident.lane1, "index.md"], "no temp-file residue either")
}
@Test("A readable-but-uneditable board refuses the write, banners it, and keeps its bytes")
func uneditableBoardBanners() throws {
let fixture = try makeBoardRoot(uneditableBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData("")
store.renameBoard("Renamed")
#expect(try fixture.indexData("") == before)
#expect(store.banners.oneShots.count == 1)
let posted = try #require(store.banners.oneShots.first)
// Enriched off the document the write refused, so the banner names the board by what it is
// still called rather than by the name that never landed.
#expect(posted.error.operation == .rename(title: "Odd"))
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't rename 'Odd' — "))
}
@Test("A read-only board refuses the rename without a second banner")
func readOnlyBoardRefusesQuietly() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
let before = try fixture.indexData("")
store.renameBoard("Q3 plan")
#expect(try fixture.indexData("") == before)
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
}
}
// MARK: - The board popover's presentation
@MainActor
@Suite("Board popover ▸ presentation")
struct BoardInfoPresentationTests {
@Test("⌘I toggles: it opens a closed popover and closes an open one")
func toggles() {
let presentation = BoardInfoPresentation()
#expect(!presentation.isPresented, "a window opens with its popover closed")
presentation.toggle()
#expect(presentation.isPresented)
presentation.toggle()
#expect(!presentation.isPresented, "the shortcut is the keyboard's way back out")
}
@Test("Two board windows never fight over one flag")
func isPerWindow() {
let first = BoardInfoPresentation()
let second = BoardInfoPresentation()
first.toggle()
#expect(first.isPresented)
#expect(!second.isPresented)
}
}
// MARK: - The window-title widget
/// The AppKit half of the board popover: the titlebar accessory and the SwiftUI widget it hosts.
///
/// Deliberately **not** a test of what the widget looks like or of the popover's behaviour, neither
/// of which a unit test can see. It is a test that a window survives having one an accessory is
/// installed during window setup, where a mistake crashes at open rather than misdraws, and this is
/// the cheapest place to find that out. `HostedWindowController`'s two promises about it (once per
/// window, off again on detach) are checked at the same time, because both are the kind of thing a
/// later refactor breaks silently.
@MainActor
@Suite("Board popover ▸ the window-title widget")
struct BoardInfoAccessoryTests {
/// A defaults domain of this suite's own `StyleEditorView` reaches the recents list, and a
/// test that wrote into `UserDefaults.standard` would be editing the developer's quick-style row.
private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) {
let name = "BoardInfoAccessoryTests-\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else {
Issue.record("could not create a defaults suite")
return (StyleRecents(defaults: .standard), {})
}
return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) })
}
private func makeWindow() -> NSWindow {
NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 500),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
}
@Test("It installs on a real window, refuses a second, and comes back off on detach")
func installsOnceAndRemoves() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let (recents, teardown) = makeRecents()
defer { teardown() }
let window = makeWindow()
let controller = HostedWindowController()
controller.attach(to: window)
controller.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
)
#expect(window.titlebarAccessoryViewControllers.count == 1)
// A host that configures itself twice must not give the titlebar two chevrons AppKit keeps
// accessories in an array and would happily hold both.
controller.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
)
#expect(window.titlebarAccessoryViewControllers.count == 1)
controller.detach()
#expect(window.titlebarAccessoryViewControllers.isEmpty, "the window is SwiftUI's; what we hang on it comes off")
}
@Test("An accessory handed over before the window exists goes in when one arrives")
func installsAfterTheWindowAttaches() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let (recents, teardown) = makeRecents()
defer { teardown() }
// The board window's real order: the load returns (and the accessory is built) before or
// after `viewDidMoveToWindow`, and neither ordering may drop it.
let controller = HostedWindowController()
controller.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
)
let window = makeWindow()
controller.attach(to: window)
#expect(window.titlebarAccessoryViewControllers.count == 1)
controller.detach()
}
}
+2
View File
@@ -18,6 +18,8 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. - **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it.
## Development ## Development
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`: The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`: