diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index dafece9..97bb9e4 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -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 diff --git a/Kanban/App/WindowAccessor.swift b/Kanban/App/WindowAccessor.swift index f423106..74a3ae5 100644 --- a/Kanban/App/WindowAccessor.swift +++ b/Kanban/App/WindowAccessor.swift @@ -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. diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 52c2785..06a98fa 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -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…, diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index da2e555..be327bd 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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, diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index f73c488..f01a6cc 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -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 diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift new file mode 100644 index 0000000..76690c9 --- /dev/null +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -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) + } + } +} diff --git a/KanbanTests/InlineEditWriteTests.swift b/KanbanTests/InlineEditWriteTests.swift index a41154d..579e6ae 100644 --- a/KanbanTests/InlineEditWriteTests.swift +++ b/KanbanTests/InlineEditWriteTests.swift @@ -1,9 +1,11 @@ +import AppKit import Foundation import Testing @testable import Kanban /// 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 /// **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) } } + +// 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() + } +} diff --git a/README.md b/README.md index 8bfe225..bd8b1a0 100644 --- a/README.md +++ b/README.md @@ -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. +- **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 The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`: