From 1e65b7c98666536f12d2795ab366dd42ae53e5db Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 09:27:01 -0400 Subject: [PATCH] Build the card window shell and lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The m4 scene plumbing was already honest — one WindowGroup value per CardWindowRef enforces one-window-per-card, and CardWindowFate's ancestor walk answered dismissal — so this card fills the window: a two-column shell whose body column takes all resize flex and whose sidebar width derives once from font metrics (26 characters of average body advance plus em gutters), the five 05-ordered section headers as placeholders, and the card body as selectable plain text until Preview mode lands. The fate walk now returns a CardPlacement (card + lane), so one pass answers both liveness and the live board › lane subtitle; a board rename lands for free through displayName. Card windows remember their frames per card in the board record (case-folded id keys, unchanged-writes-nothing), restoring instead of cascading; only unremembered cards take the last-used size and cascade. Store acquisition stays gated on liveStore — a card window never opens a board — and the close-flush hook stands with nothing to flush until the Edit-session card. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- Kanban/App/CardWindowHost.swift | 143 +++++++++++--- Kanban/KanbanApp.swift | 5 + Kanban/LiveStore/BoardRegistry.swift | 43 +++++ Kanban/UI/Card/CardWindowMetrics.swift | 112 +++++++++++ Kanban/UI/Card/CardWindowView.swift | 159 +++++++++++++++ KanbanTests/CardWindowFateTests.swift | 16 +- KanbanTests/CardWindowShellTests.swift | 258 +++++++++++++++++++++++++ README.md | 4 +- 8 files changed, 706 insertions(+), 34 deletions(-) create mode 100644 Kanban/UI/Card/CardWindowMetrics.swift create mode 100644 Kanban/UI/Card/CardWindowView.swift create mode 100644 KanbanTests/CardWindowShellTests.swift diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 65a0f40..dc8462d 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -4,6 +4,18 @@ import os // MARK: - Fate +/// Where a card window's card is right now: the card itself, and the lane it is currently in. +/// +/// **One resolution answers both of the window's questions**, which is why the lane rides along +/// rather than being looked up a second time: the card is what the window renders, and the lane is +/// half of its subtitle ("⟨board⟩ › ⟨lane⟩", live-updating as the card moves — 05-card-window.md +/// ▸ Window). Two walks over the snapshot could disagree about which lane a card is in for exactly +/// one frame, and that frame is the one where the card just moved. +public struct CardPlacement: Equatable { + public let card: Card + public let lane: Lane +} + /// What the current snapshot says about a card window: render this card, or go away. /// /// A named decision rather than a scattering of `if`s, because 05-card-window.md ▸ Deletion & @@ -11,13 +23,14 @@ import os /// and both have to be true of one piece of code. Making it a value also makes it a *pure* function /// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window. public enum CardWindowFate: Equatable { - case shows(Card) + case shows(CardPlacement) case dismisses } // MARK: - The session seam -/// A card window's editor session — m4's no-op stand-in for the thing 05-card-window.md will build. +/// A card window's editor session — still a no-op stand-in for the thing 05-card-window.md will +/// build, and deliberately so: **the shell has no Edit session to flush yet.** /// /// It exists so the close flush has something real to call and something real to be *ordered against* /// (see `CardSessionFlushing`). The only behaviour it has is the one the ordering depends on: ending @@ -32,8 +45,11 @@ final class CardWindowSession: CardSessionFlushing { func endSession() async { guard !hasEnded else { return } hasEnded = true - // m6: commit the open Edit session here (06-history-undo.md's session granularity), flushing - // the debounced body save first. + // m6-card-body: commit the open Edit session here (06-history-undo.md's session + // granularity), flushing the debounced body save first — and, on a dismissal caused by a + // tombstone, the surgical body write 05 ▸ Deletion & lifecycle promises ("dismissal never + // eats typed work silently where a save can land"). Nothing exists to flush until the + // editor does; the hook's *position* in the sequence is what this milestone pins. } } @@ -57,8 +73,12 @@ final class CardWindowSession: CardSessionFlushing { /// flush dismissed this one) and the odd one (the system restoring a card window from a previous /// launch, which scene restoration is disabled precisely to prevent). /// -/// The content is a placeholder — the two-column composition, the sidebar, Edit/Preview and the rest -/// are the card-window milestone's. +/// ### It is the window; `CardWindowView` is the content +/// +/// This file owns identity, lifecycle, the title and subtitle, and where the window opens. The +/// two-column composition inside it is `CardWindowView`'s, and what fills those columns — the title +/// field, Preview/Edit, the raw-source outlet, the sidebar's five sections — arrives card by card +/// underneath a composition that does not move. struct CardWindowHost: View { let ref: CardWindowRef @@ -93,18 +113,42 @@ struct CardWindowHost: View { let identity = ItemID(rawValue: cardID) for lane in snapshot.lanes { guard let card = lane.cards.first(where: { $0.id == identity }) else { continue } - return lane.isDeleted || card.isDeleted ? .dismisses : .shows(card) + return lane.isDeleted || card.isDeleted + ? .dismisses + : .shows(CardPlacement(card: card, lane: lane)) } return .dismisses } + /// The window's subtitle: "⟨board⟩ › ⟨lane⟩" (05-card-window.md ▸ Window). + /// + /// Pure, and taking both names as strings, because the *live* half is what matters and is the + /// easy half to lose: the board name follows a board rename through + /// `AppModel.displayName(of:)`, and the lane name follows the card between lanes because it is + /// re-derived from every snapshot rather than captured when the window opened. A window that + /// kept showing the lane its card was in an hour ago would be wrong in exactly the case the + /// subtitle exists for. + /// + /// Untitled lanes render the same placeholder the board's lane header does — "Untitled" is a + /// rendering, never a value (03-board-ui.md § Card face). + static func subtitle(board: String, lane: String?) -> String { + "\(board) › \(lane ?? "Untitled")" + } + // MARK: - View var body: some View { content - .frame(minWidth: 360, minHeight: 240) + // Derived, like every other measurement in this window: the minimum is what the two + // columns need at the current text size, not a number chosen once at 13pt + // (`CardWindowMetrics`). + .frame(minWidth: minimumSize.width, minHeight: minimumSize.height) .background(WindowAccessor(controller: windowController)) .navigationTitle(windowTitle) + // The window follows its card: both of these are re-derived from every snapshot, so a + // rename retitles the window and a lane move re-subtitles it with no notification of + // our own (05-card-window.md ▸ Window). + .navigationSubtitle(windowSubtitle) .task { start() } .onChange(of: shouldDismiss, initial: true) { _, dismisses in guard dismisses else { return } @@ -113,34 +157,42 @@ struct CardWindowHost: View { .onDisappear { finish() } } + private var minimumSize: CGSize { + CardWindowMetrics.minimumSize(bodyPointSize: CardWindowMetrics.bodyPointSize) + } + @ViewBuilder private var content: some View { - if let card { - VStack(alignment: .leading, spacing: 12) { - Text(card.title.value ?? "Untitled") - .font(.title) - // The untitled placeholder is styling, not a title: a card with no `title` key - // shows the word in secondary, never as if somebody had typed it - // (01-storage-format.md § Frontmatter). - .foregroundStyle(card.title.value == nil ? .secondary : .primary) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(24) + if let placement { + CardWindowView(card: placement.card) } else { + // Nothing to render and nothing worth animating: this window is on its way out. Color.clear } } - private var card: Card? { + /// Where this window's card is in this board's snapshot, or `nil` when it is not — which is the + /// same condition `shouldDismiss` reads, one moment before the window goes. + private var placement: CardPlacement? { guard case let .open(store) = phase, - case let .shows(card) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) + case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) else { return nil } - return card + return placement } + /// The card's title, with the face's own untitled rendering — a card with no `title` key is + /// "Untitled" in the title bar and the Window menu, never a blank strip. private var windowTitle: String { - card?.title.value ?? "" + guard let placement else { return "" } + return placement.card.title.value ?? "Untitled" + } + + private var windowSubtitle: String { + guard case let .open(store) = phase, let placement else { return "" } + return Self.subtitle( + board: AppModel.displayName(of: store), + lane: placement.lane.title.value + ) } /// The dismissal decision, re-evaluated on every snapshot the store applies. @@ -188,15 +240,39 @@ struct CardWindowHost: View { configureWindow() } - /// Size and placement: the last-used card-window size, cascaded (05-card-window.md, "New windows - /// open at the last-used card-window size, cascaded"). + /// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md + /// ▸ Window: "New windows open at the last-used card-window size, cascaded; frames restore per + /// card across relaunch where state restoration allows"). /// - /// The size is app-wide rather than per-board or per-card — 02 § Per-board app state files "the - /// last-used card-window size" under App-wide state explicitly. Per-*card* frame restoration is a - /// separate promise in 05 ("frames restore per card across relaunch where state restoration - /// allows") and belongs to the card-window milestone, which owns the per-card record it needs. + /// Two memories, and they answer different questions: + /// + /// - **The last-used size is app-wide.** 02 § Per-board app state files "the last-used + /// card-window size" under App-wide state explicitly, so it lives in `AppPreferences` and a + /// card window opened on any board inherits the size of the last one the user sized. + /// - **The frame is per card**, and lives on the board's registry record beside the board + /// window's own frame — same home, same identity-keyed record, same files-first rule (nothing + /// app-private is ever written into the board folder). "Where state restoration allows" is + /// this: AppKit's scene restoration is disabled app-wide on purpose (`KanbanApp`), so a card + /// window does not come back by itself at relaunch — but the *next* time the user opens that + /// card, it opens where they left it. + /// + /// A card with a remembered frame therefore does **not** cascade: a cascade over a deliberate + /// placement would move a window the user had already put somewhere. Only the windows with + /// nothing remembered take the running cascade point, which is what keeps a burst of freshly + /// opened cards from landing on top of each other. private func configureWindow() { + // Read once here rather than per callback: this window's board has a session by now (the + // caller just registered against it), and the record id is what both memories are keyed on. + let recordID = appModel.session(for: ref.board)?.recordID + windowController.onAttach = { window in + if let recordID, + let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) { + // Repositioned onto a live screen when the saved one is gone — the board window's + // own rule, shared rather than restated (`HostedWindowController.placement`). + window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true) + return + } if let size = AppPreferences.lastCardWindowSize { window.setContentSize(size) } @@ -209,6 +285,13 @@ struct CardWindowHost: View { } windowController.onFrameChanged = { frame in + if let recordID { + appModel.boardRegistry.updateCardWindowFrame( + id: recordID, + cardID: ref.cardIdentity, + frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height) + ) + } guard let window = windowController.window else { return } let size = window.contentRect(forFrameRect: frame).size guard size != AppPreferences.lastCardWindowSize else { return } diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 74798f9..616494f 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -113,6 +113,11 @@ struct KanbanApp: App { } .restorationBehavior(.disabled) .defaultLaunchBehavior(.suppressed) + // The **first** card window's size, and only that one: every later window opens at the + // last-used size or at its card's remembered frame, both applied by the host as the window + // attaches (05-card-window.md ▸ Window). Derived from font metrics like every other + // measurement in that window rather than written down in points. + .defaultSize(CardWindowMetrics.defaultSize(bodyPointSize: CardWindowMetrics.bodyPointSize)) Settings { SettingsView() diff --git a/Kanban/LiveStore/BoardRegistry.swift b/Kanban/LiveStore/BoardRegistry.swift index 11adfa8..f2dd8c2 100644 --- a/Kanban/LiveStore/BoardRegistry.swift +++ b/Kanban/LiveStore/BoardRegistry.swift @@ -99,6 +99,25 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { public var windowFrame: WindowFrame? + /// This board's **card** window frames, keyed by card GUID (05-card-window.md ▸ Window: "frames + /// restore per card across relaunch where state restoration allows"). + /// + /// Here rather than in `UserDefaults` for the reason the board's own frame is here: a frame + /// belongs to a board that the bookmark follows through renames and moves, and a path-keyed + /// preference would lose every card frame the first time the board was renamed. And here rather + /// than in the board folder because files-first is absolute — a window frame is per-machine app + /// state, never board data. + /// + /// **The key is the card id case-folded** (`ItemID`'s comparison rule), so a folder respelled + /// `ABC…` finds the frame stored under `abc…` — the same identity rule the window key itself + /// uses, since the two must agree about what "this card" means. + /// + /// The honest residual: entries accumulate for every card the user has ever opened a window for + /// on this board, and a card deleted afterwards leaves its entry behind. Accepted — the record + /// is per-machine convenience state measured in tens of bytes per entry, and it dies wholesale + /// with Forget like everything else here. + public var cardWindowFrames: [String: WindowFrame]? + /// The board's `icon` — registry-cached with live write-through, beside `displayName` /// (02-architecture.md § Per-board app state: "The row's title and icon are registry-cached /// too — with live write-through"). `nil` when the board's `icon` key is missing or @@ -150,6 +169,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { laneCount: Int? = nil, cardCount: Int? = nil, windowFrame: WindowFrame? = nil, + cardWindowFrames: [String: WindowFrame]? = nil, isOpenNow: Bool? = nil, pushOnCommit: Bool = false, remoteLocationWarned: Bool = false, @@ -164,6 +184,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { self.laneCount = laneCount self.cardCount = cardCount self.windowFrame = windowFrame + self.cardWindowFrames = cardWindowFrames self.isOpenNow = isOpenNow self.pushOnCommit = pushOnCommit self.remoteLocationWarned = remoteLocationWarned @@ -434,6 +455,28 @@ public final class BoardRegistry { update(id) { $0.windowFrame = frame } } + /// One card window's remembered frame on this board, or `nil` when that card has never had one + /// (05-card-window.md ▸ Window). The caller decides what "no frame" means — for a card window it + /// means "open at the last-used size and cascade". + public func cardWindowFrame(id: UUID, cardID: ItemID) -> WindowFrame? { + record(id: id)?.cardWindowFrames?[cardID.canonicalValue] + } + + /// Remembers one card window's frame. Keyed by `ItemID`'s own comparison value, so the read + /// above finds it whatever case the card's folder is spelled in. + /// + /// **Unchanged writes nothing**, `syncDisplayState`'s rule: a card window reports its frame on + /// every move and at the end of every live resize, and the board window's twin already saves on + /// each of those — this one must not add a registry file write for a frame that did not move. + public func updateCardWindowFrame(id: UUID, cardID: ItemID, frame: WindowFrame) { + guard cardWindowFrame(id: id, cardID: cardID) != frame else { return } + update(id) { record in + var frames = record.cardWindowFrames ?? [:] + frames[cardID.canonicalValue] = frame + record.cardWindowFrames = frames + } + } + /// The live write-through for an *open* board's title, icon, and iconColor /// (02-architecture.md § Per-board app state, "these three refresh whenever an open board's /// reload changes them"). `BoardStore` calls into this — indirectly, through the delegate diff --git a/Kanban/UI/Card/CardWindowMetrics.swift b/Kanban/UI/Card/CardWindowMetrics.swift new file mode 100644 index 0000000..7b6490f --- /dev/null +++ b/Kanban/UI/Card/CardWindowMetrics.swift @@ -0,0 +1,112 @@ +import AppKit +import CoreGraphics + +/// The card window's fixed geometry — **derived from font metrics, never written down in points** +/// (05-card-window.md ▸ Composition: the attributes sidebar has a "fixed narrow width derived from +/// font metrics (full relative scaling, 10-accessibility.md)"; 10 ▸ Text: "relative text styles +/// everywhere, no fixed point sizes … metrics derive from font metrics, so layout survives the +/// largest system text sizes"). +/// +/// ### The derivation, named once +/// +/// Every width here is **a character count in the body font**, and the arithmetic behind it is one +/// expression used three times: +/// +/// ``` +/// width = characters × averageCharacterAdvance × pointSize + 2 × gutter +/// ``` +/// +/// `averageCharacterAdvance` is the system font's rough average advance for mixed-case Latin text as +/// a fraction of its point size — half an em, the classic typesetter's estimate. It is deliberately +/// an *estimate* rather than a measurement: the sidebar is sized so a filename, a palette grid and a +/// key/value row have room, not so any particular string fits exactly, and a measured advance would +/// make this geometry depend on which glyphs happened to be on screen. The gutter is one em, which +/// is what keeps the whole thing scaling together. +/// +/// ### Why the point size is a parameter +/// +/// So the rule is a pure function and a test can hold it still. `bodyPointSize` below is the one +/// place that asks the system what the body font actually is; everything else takes it as an +/// argument, which is also what makes "the sidebar is narrower at 11pt and wider at 18pt" a fact a +/// suite can assert rather than something to be verified by eye at three text sizes. +enum CardWindowMetrics { + + // MARK: - The unit + + /// The system font's approximate average advance per character, as a fraction of its point size. + static let averageCharacterAdvance: CGFloat = 0.5 + + /// The horizontal inset on each side of a column: one em, so it scales with everything else. + static func gutter(bodyPointSize: CGFloat) -> CGFloat { + bodyPointSize + } + + /// A column `characters` body-characters wide, gutters included — the one expression. + static func columnWidth(characters: CGFloat, bodyPointSize: CGFloat) -> CGFloat { + let text = characters * averageCharacterAdvance * bodyPointSize + return (text + 2 * gutter(bodyPointSize: bodyPointSize)).rounded() + } + + /// A line's height in the body font — the vertical counterpart of the advance, used only for the + /// window's minimum and default heights. + static func lineHeight(bodyPointSize: CGFloat) -> CGFloat { + bodyPointSize * 1.4 + } + + // MARK: - The sidebar + + /// How wide the attributes sidebar is, in characters. Narrow by contract — it holds a + /// middle-truncated filename, a palette grid and a key/value row, and nothing in it ever wants + /// the window's spare width, which all goes to the body (05 ▸ Composition). + static let sidebarCharacters: CGFloat = 26 + + /// **The sidebar's width, and the only place it is decided.** Fixed for a given text size: the + /// window's resize flex goes entirely to the body column, so this is not a fraction of anything. + static func sidebarWidth(bodyPointSize: CGFloat) -> CGFloat { + columnWidth(characters: sidebarCharacters, bodyPointSize: bodyPointSize) + } + + // MARK: - The body column + + /// The narrowest the body column is allowed to get — a measure of prose short enough to be a + /// floor rather than a preference. + static let bodyMinimumCharacters: CGFloat = 44 + + /// The body column at its resting default: a comfortable measure, which the user then resizes. + static let bodyDefaultCharacters: CGFloat = 74 + + static func bodyMinimumWidth(bodyPointSize: CGFloat) -> CGFloat { + columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize) + } + + // MARK: - The window + + /// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough + /// for a title, its date line and a few lines of body. + static func minimumSize(bodyPointSize: CGFloat) -> CGSize { + CGSize( + width: sidebarWidth(bodyPointSize: bodyPointSize) + bodyMinimumWidth(bodyPointSize: bodyPointSize), + height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded() + ) + } + + /// The size a card window opens at when there is no last-used size to open at — a first-ever + /// card window, and nothing else (05 ▸ Window: "New windows open at the last-used card-window + /// size, cascaded"; `AppPreferences.lastCardWindowSize` is that memory). + static func defaultSize(bodyPointSize: CGFloat) -> CGSize { + CGSize( + width: sidebarWidth(bodyPointSize: bodyPointSize) + + columnWidth(characters: bodyDefaultCharacters, bodyPointSize: bodyPointSize), + height: (lineHeight(bodyPointSize: bodyPointSize) * 32).rounded() + ) + } + + // MARK: - The live metric + + /// The body font's point size as the system currently reports it — the one impure read, kept to + /// one line so every derivation above stays testable. + @MainActor + static var bodyPointSize: CGFloat { + NSFont.preferredFont(forTextStyle: .body).pointSize + } +} diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift new file mode 100644 index 0000000..a7d40e2 --- /dev/null +++ b/Kanban/UI/Card/CardWindowView.swift @@ -0,0 +1,159 @@ +import SwiftUI + +// MARK: - CardWindowView + +/// The card window's content: **two full-height, independently scrolling columns** — a wide body +/// column leading, a narrow attributes sidebar trailing (05-card-window.md ▸ Composition). +/// +/// ### What this milestone builds, and what it deliberately does not +/// +/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only +/// renderings of what the loader already knows — the card's title, its created/modified line, and +/// its body as plain text. Everything that reads or writes beyond that is later work and is marked +/// where it lands: +/// +/// - the title as an editable field (commit on Return / focus loss, Escape abandons), +/// - the body's Preview/Edit pairing and the raw-source outlet, +/// - the sidebar's five sections, which are section *headers* here and nothing more. +/// +/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are +/// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in +/// underneath without the composition moving. +/// +/// ### The width rule, in one line +/// +/// The sidebar has a fixed width from `CardWindowMetrics`; the body column takes `.infinity`. That +/// is the whole of "the window's resize flex goes to the body" — no split view, no stored divider +/// position, nothing for a drag to disagree with. +struct CardWindowView: View { + + let card: Card + + /// The body font's point size, read once per body evaluation: every measurement in this view — + /// the sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale + /// together when the system text size changes. + private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize } + + var body: some View { + HStack(spacing: 0) { + bodyColumn + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + Divider() + + sidebar + // Fixed, and the one place it comes from. + .frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize)) + .frame(maxHeight: .infinity, alignment: .top) + .background(.background.secondary) + } + } + + // MARK: - Body column + + /// Title, the quiet created/modified line, then the body — 05's top-to-bottom order. + private var bodyColumn: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: bodyPointSize * 0.75) { + // m6-card-body: the title *field* — large and borderless, committing to frontmatter + // on Return or focus loss, clearing to remove the `title` key, Escape abandoning to + // the on-disk title. Read-only here; the placeholder rendering is already final. + Text(card.title.value ?? "Untitled") + .font(.largeTitle) + // "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the + // same secondary treatment the face gives it. + .foregroundStyle(card.title.value == nil ? .secondary : .primary) + .textSelection(.enabled) + + if let dateLine { + Text(dateLine) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + // m6-card-body: Preview/Edit proper — a rendered preview with live task-list + // checkboxes, and a syntax-highlighted raw editor behind ⌘E. Plain selectable text + // until then: honest about being unrendered rather than half-rendering Markdown. + if !card.body.isEmpty { + Text(card.body) + .font(.body) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) + } + } + + /// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent** + /// (05 ▸ Composition) — the whole line disappears when the card carries none of the three. + /// + /// The "by" segment renders only with the self-reported provenance stamp present + /// (01-storage-format.md), which is the point of showing it at all: provenance made visible where + /// git history may not exist. + private var dateLine: String? { + var parts: [String] = [] + if let created = card.created.value { + parts.append("Created \(Self.dateText(created))") + } + if let modified = card.modified.value { + parts.append("Modified \(Self.dateText(modified))") + } + if let by = card.modifiedBy.value, !by.isEmpty { + parts.append("by \(by)") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private static func dateText(_ date: Date) -> String { + date.formatted(date: .abbreviated, time: .shortened) + } + + // MARK: - Attributes sidebar + + /// The sidebar's sections, **in 05's settled order**, as headers over empty space. + /// + /// Two of them are conditional once they have content — Details appears only when the card + /// carries unknown frontmatter keys, and History is absent on boards without app-managed git — + /// and the shell shows them unconditionally because it has neither the key inventory nor a git + /// mode to consult yet. That is the one place these placeholders are not yet the final + /// composition, and it resolves when the sections do. + private var sidebar: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: bodyPointSize * 1.25) { + // m6-card-attachments: every top-level file of `attachments/`, compact rows with a + // QuickLook thumbnail, keyboard-navigable. + section("Attachments") + // m6-card-sidebar: the embedded style editor — the same component the board popover + // and Style… already host (`StyleEditor`). + section("Style") + // m6-card-sidebar: read-only key/value rows for every unknown frontmatter key, in + // file order. + section("Details") + // m7-git: the card's commit trail, read-only; absent on mode none / repo-nested. + section("History") + // m6-card-sidebar: Delete (tombstones, the window then dismisses itself) and Reveal + // in Finder. + section("Actions") + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) + } + } + + /// A stacked small-caps header over the space its section will occupy (05: "Stacked sections + /// under small-caps headers"). + private func section(_ title: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Divider() + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} diff --git a/KanbanTests/CardWindowFateTests.swift b/KanbanTests/CardWindowFateTests.swift index faf139d..7876210 100644 --- a/KanbanTests/CardWindowFateTests.swift +++ b/KanbanTests/CardWindowFateTests.swift @@ -32,8 +32,15 @@ private func makeBoard() throws -> WriterFixture { } private func title(_ fate: CardWindowFate) -> String? { - guard case let .shows(card) = fate else { return nil } - return card.title.value + guard case let .shows(placement) = fate else { return nil } + return placement.card.title.value +} + +/// The lane the fate says the card is in — the subtitle's live half, resolved by the very same walk +/// that decides whether the window lives at all (`CardPlacement`). +private func laneTitle(_ fate: CardWindowFate) -> String? { + guard case let .shows(placement) = fate else { return nil } + return placement.lane.title.value } // MARK: - Tests @@ -42,7 +49,7 @@ private func title(_ fate: CardWindowFate) -> String? { @Suite("Card window fate") struct CardWindowFateTests { - @Test("A live card in a live lane keeps its window") + @Test("A live card in a live lane keeps its window, and names the lane it is in") func aLiveCardShows() throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -50,6 +57,9 @@ struct CardWindowFateTests { let fate = CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) #expect(title(fate) == "Fix login") + // The window's subtitle rides on this same resolution rather than on a second walk, so the + // lane it reports and the card it renders can never be one snapshot apart. + #expect(laneTitle(fate) == "Todo") } @Test("A tombstoned card dismisses its window") diff --git a/KanbanTests/CardWindowShellTests.swift b/KanbanTests/CardWindowShellTests.swift new file mode 100644 index 0000000..7bdfaa2 --- /dev/null +++ b/KanbanTests/CardWindowShellTests.swift @@ -0,0 +1,258 @@ +import Foundation +import Testing +@testable import Kanban + +/// The card window's shell is mostly window plumbing, which a unit test cannot see. What it *can* +/// see is the three seams that plumbing hangs off, and each of them is a thing that fails silently: +/// a subtitle that stops following its card reads as correct until the card moves, a sidebar width +/// written down in points looks fine until the system text size changes, and a second window for one +/// card looks like a window that simply did not focus. +/// +/// So the seams are pure functions and values, and this is their suite. The dismissal verdict — +/// vanished, tombstoned, ancestor-tombstoned, cross-board — is `CardWindowFateTests`'. + +// MARK: - Fixtures + +/// A two-lane board with the card in the first one, plus an untitled lane for the placeholder case. +/// +/// - "Todo": one live card +/// - "Doing": empty, the card's destination +/// - untitled: empty +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item(Ident.lane3, "---\nschema: 1\norder: 3072\n---\nno title here\n") + return fixture +} + +private func snapshot(of fixture: WriterFixture) throws -> BoardModel { + try BoardLoader.load(boardRoot: fixture.root).model +} + +/// Exactly what `CardWindowHost.windowSubtitle` composes — the fate's lane, through the subtitle +/// rule — so this helper cannot pass while the window shows something else. +@MainActor +private func subtitle(forCard cardID: String, boardNamed board: String, in model: BoardModel) -> String? { + guard case let .shows(placement) = CardWindowHost.cardWindowFate(cardID: cardID, in: model) else { + return nil + } + return CardWindowHost.subtitle(board: board, lane: placement.lane.title.value) +} + +/// A registry whose file lives in temp rather than in the test host's Application Support. +@MainActor +private struct RegistryStorage { + let folder: URL + + var url: URL { folder.appendingPathComponent("board-registry.json", isDirectory: false) } + + init() throws { + folder = FileManager.default.temporaryDirectory + .appendingPathComponent("CardWindowShellTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + + func tearDown() { + try? FileManager.default.removeItem(at: folder) + } +} + +// MARK: - Subtitle + +@MainActor +@Suite("Card window subtitle") +struct CardWindowSubtitleTests { + + @Test("The subtitle is ⟨board⟩ › ⟨lane⟩") + func subtitleNamesBoardAndLane() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + #expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Todo") + } + + @Test("The subtitle follows the card between lanes") + func subtitleFollowsTheCard() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + #expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Todo") + + // A lane move, as the disk sees it: the card's folder changes parent, its UUID does not. + // The window's key names neither lane, so the window stays — and the subtitle is the one + // part of it that has to notice. + try FileManager.default.moveItem( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + to: fixture.url("\(Ident.lane2)/\(Ident.card1)") + ) + + #expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Doing") + } + + @Test("An untitled lane subtitles with the placeholder, never with a blank") + func anUntitledLaneRendersThePlaceholder() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try FileManager.default.moveItem( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + to: fixture.url("\(Ident.lane3)/\(Ident.card1)") + ) + + // "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — a subtitle reading + // "Work › " would look like a bug in the app rather than a lane nobody has named. + #expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Untitled") + } + + @Test("A card with no window has no subtitle to compose") + func aDismissedCardHasNoSubtitle() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + #expect(subtitle(forCard: Ident.card4, boardNamed: "Work", in: try snapshot(of: fixture)) == nil) + } + + @Test("The board half is whatever the board is currently called") + func theBoardHalfIsTheDisplayName() { + // `AppModel.displayName(of:)` is what the host passes in — title, falling back to the folder + // name — so a board rename lands here for free. The rule itself is `AppModelTests`'. + #expect(CardWindowHost.subtitle(board: "Renamed", lane: "Todo") == "Renamed › Todo") + #expect(CardWindowHost.subtitle(board: "Work", lane: nil) == "Work › Untitled") + } +} + +// MARK: - Metrics + +@Suite("Card window metrics") +struct CardWindowMetricsTests { + + @Test("The sidebar's width is a character count in the body font") + func sidebarWidthIsDerivedFromFontMetrics() { + // 26 characters at half an em, plus a one-em gutter on each side. Pinned at one point size + // so a change to the derivation has to be a deliberate one: 26 × 0.5 × 16 + 2 × 16 = 240. + #expect(CardWindowMetrics.sidebarWidth(bodyPointSize: 16) == 240) + #expect( + CardWindowMetrics.sidebarWidth(bodyPointSize: 16) + == CardWindowMetrics.columnWidth(characters: CardWindowMetrics.sidebarCharacters, bodyPointSize: 16) + ) + } + + @Test("The sidebar scales with the body font, in both directions") + func sidebarWidthScalesWithTheFont() { + // The whole point of deriving it: at the largest system text sizes the sidebar grows with + // the text it holds instead of truncating everything in it (10-accessibility.md ▸ Text). + let small = CardWindowMetrics.sidebarWidth(bodyPointSize: 11) + let standard = CardWindowMetrics.sidebarWidth(bodyPointSize: 13) + let large = CardWindowMetrics.sidebarWidth(bodyPointSize: 24) + + #expect(small < standard) + #expect(standard < large) + // And it is the same width every time it is asked, which is what "fixed width" means here: + // the window's resize flex goes to the body, never to this column. + #expect(CardWindowMetrics.sidebarWidth(bodyPointSize: 13) == standard) + } + + @Test("The sidebar is the narrow column and the body is the wide one") + func theSidebarIsNarrowerThanTheBodysFloor() { + for size in [11.0, 13.0, 18.0, 24.0] as [CGFloat] { + #expect( + CardWindowMetrics.sidebarWidth(bodyPointSize: size) + < CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size), + "even squeezed to its floor, the body column stays the wide one" + ) + } + } + + @Test("The window's minimum is the sidebar plus the body's floor") + func theWindowMinimumIsTheSumOfTheColumns() { + let size: CGFloat = 13 + #expect( + CardWindowMetrics.minimumSize(bodyPointSize: size).width + == CardWindowMetrics.sidebarWidth(bodyPointSize: size) + + CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size), + "the minimum is what the two columns need, not a number somebody liked" + ) + #expect(CardWindowMetrics.minimumSize(bodyPointSize: size).height > 0) + } + + @Test("A first card window opens larger than the minimum") + func theDefaultSizeIsRoomier() { + let size: CGFloat = 13 + let minimum = CardWindowMetrics.minimumSize(bodyPointSize: size) + let initial = CardWindowMetrics.defaultSize(bodyPointSize: size) + + #expect(initial.width > minimum.width) + #expect(initial.height > minimum.height) + } +} + +// MARK: - Identity and frames + +@MainActor +@Suite("Card window identity") +struct CardWindowIdentityTests { + + @Test("Opening the same card twice is one window's worth of bookkeeping") + func reopeningACardDoesNotDuplicateIt() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let storage = try RegistryStorage() + defer { storage.tearDown() } + + let model = AppModel(registryStorageURL: storage.url) + let board = BoardWindowRef(url: fixture.root) + let recordID = model.boardRegistry.recordOpen(of: fixture.root) + let store = try model.storeRegistry.acquire(fixture.root) + model.beginSession(ref: board, store: store, recordID: recordID, access: nil) + defer { model.storeRegistry.release(store) } + + // The dedup itself is the scene's: `openWindow(value:)` with an equal ref focuses the window + // that ref already has (`WindowRefsTests` pins the equality). What this side must not do is + // *record* a second one — a duplicate here would have the close flush wait on a window that + // does not exist. + let first = CardWindowRef(board: board, cardID: ItemID(rawValue: Ident.card1)) + let reopened = CardWindowRef(board: board, cardID: ItemID(rawValue: Ident.card1.uppercased())) + #expect(first == reopened) + + model.registerCardWindow(first, session: CardWindowSession()) + model.registerCardWindow(reopened, session: CardWindowSession()) + #expect(model.session(for: board)?.cardRefs.count == 1) + + // And one unregister drains it, because there was only ever one. + model.unregisterCardWindow(reopened) + #expect(model.session(for: board)?.cardRefs.isEmpty == true) + } + + @Test("A card window's frame is remembered per card, on its board's record") + func cardFramesArePerCardAndSurviveARelaunch() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let storage = try RegistryStorage() + defer { storage.tearDown() } + + let registry = BoardRegistry(storageURL: storage.url) + let recordID = registry.recordOpen(of: fixture.root) + let one = ItemID(rawValue: Ident.card1) + let two = ItemID(rawValue: Ident.card2) + + #expect(registry.cardWindowFrame(id: recordID, cardID: one) == nil, "an unopened card cascades instead") + + registry.updateCardWindowFrame(id: recordID, cardID: one, frame: WindowFrame(x: 10, y: 20, width: 700, height: 600)) + registry.updateCardWindowFrame(id: recordID, cardID: two, frame: WindowFrame(x: 90, y: 80, width: 500, height: 400)) + + // A new instance is a relaunch: the frames come back from the file, per card, which is the + // whole of "frames restore per card across relaunch where state restoration allows" in an + // app whose scene restoration is deliberately off. + let relaunched = BoardRegistry(storageURL: storage.url) + #expect(relaunched.cardWindowFrame(id: recordID, cardID: one) == WindowFrame(x: 10, y: 20, width: 700, height: 600)) + #expect(relaunched.cardWindowFrame(id: recordID, cardID: two) == WindowFrame(x: 90, y: 80, width: 500, height: 400)) + + // Keyed the way the window itself is keyed: a folder respelled in caps is the same card, so + // it must find the frame the other spelling stored rather than opening somewhere else. + let respelled = ItemID(rawValue: Ident.card1.uppercased()) + #expect(relaunched.cardWindowFrame(id: recordID, cardID: respelled) == WindowFrame(x: 10, y: 20, width: 700, height: 600)) + } +} diff --git a/README.md b/README.md index a679f3f..cd72a18 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Lanework is in early development. This list tracks what has actually shipped and - **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards. - **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. - **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state. -- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. +- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. - **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away. - **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job. - **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock. @@ -32,6 +32,8 @@ Lanework is in early development. This list tracks what has actually shipped and - **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — tombstoned items carried, strays and timestamps untouched. +- **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ › ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar's sections are stacked headers awaiting their content. Reopening a live card focuses the window it already has, and the window closes itself the moment its card stops being live — deleted, tombstoned, buried under a tombstoned lane, or moved to another board. + - **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