Build the card window shell and lifecycle

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
This commit is contained in:
2026-07-28 09:27:01 -04:00
parent af1860debf
commit 1e65b7c986
8 changed files with 706 additions and 34 deletions
+113 -30
View File
@@ -4,6 +4,18 @@ import os
// MARK: - Fate // 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. /// 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 & /// 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 /// 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. /// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window.
public enum CardWindowFate: Equatable { public enum CardWindowFate: Equatable {
case shows(Card) case shows(CardPlacement)
case dismisses case dismisses
} }
// MARK: - The session seam // 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* /// 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 /// (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 { func endSession() async {
guard !hasEnded else { return } guard !hasEnded else { return }
hasEnded = true hasEnded = true
// m6: commit the open Edit session here (06-history-undo.md's session granularity), flushing // m6-card-body: commit the open Edit session here (06-history-undo.md's session
// the debounced body save first. // 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 /// 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). /// 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 /// ### It is the window; `CardWindowView` is the content
/// are the card-window milestone's. ///
/// 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 { struct CardWindowHost: View {
let ref: CardWindowRef let ref: CardWindowRef
@@ -93,18 +113,42 @@ struct CardWindowHost: View {
let identity = ItemID(rawValue: cardID) let identity = ItemID(rawValue: cardID)
for lane in snapshot.lanes { for lane in snapshot.lanes {
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue } 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 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 // MARK: - View
var body: some View { var body: some View {
content 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)) .background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle) .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() } .task { start() }
.onChange(of: shouldDismiss, initial: true) { _, dismisses in .onChange(of: shouldDismiss, initial: true) { _, dismisses in
guard dismisses else { return } guard dismisses else { return }
@@ -113,34 +157,42 @@ struct CardWindowHost: View {
.onDisappear { finish() } .onDisappear { finish() }
} }
private var minimumSize: CGSize {
CardWindowMetrics.minimumSize(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
if let card { if let placement {
VStack(alignment: .leading, spacing: 12) { CardWindowView(card: placement.card)
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)
} else { } else {
// Nothing to render and nothing worth animating: this window is on its way out.
Color.clear 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, 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 } 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 { 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. /// The dismissal decision, re-evaluated on every snapshot the store applies.
@@ -188,15 +240,39 @@ struct CardWindowHost: View {
configureWindow() configureWindow()
} }
/// Size and placement: the last-used card-window size, cascaded (05-card-window.md, "New windows /// Size and placement **the remembered frame first, the cascade second** (05-card-window.md
/// open at the last-used card-window size, cascaded"). /// 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 /// Two memories, and they answer different questions:
/// 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 /// - **The last-used size is app-wide.** 02 § Per-board app state files "the last-used
/// allows") and belongs to the card-window milestone, which owns the per-card record it needs. /// 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() { 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 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 { if let size = AppPreferences.lastCardWindowSize {
window.setContentSize(size) window.setContentSize(size)
} }
@@ -209,6 +285,13 @@ struct CardWindowHost: View {
} }
windowController.onFrameChanged = { frame in 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 } guard let window = windowController.window else { return }
let size = window.contentRect(forFrameRect: frame).size let size = window.contentRect(forFrameRect: frame).size
guard size != AppPreferences.lastCardWindowSize else { return } guard size != AppPreferences.lastCardWindowSize else { return }
+5
View File
@@ -113,6 +113,11 @@ struct KanbanApp: App {
} }
.restorationBehavior(.disabled) .restorationBehavior(.disabled)
.defaultLaunchBehavior(.suppressed) .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 { Settings {
SettingsView() SettingsView()
+43
View File
@@ -99,6 +99,25 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
public var windowFrame: WindowFrame? 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` /// 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 /// (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 /// 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, laneCount: Int? = nil,
cardCount: Int? = nil, cardCount: Int? = nil,
windowFrame: WindowFrame? = nil, windowFrame: WindowFrame? = nil,
cardWindowFrames: [String: WindowFrame]? = nil,
isOpenNow: Bool? = nil, isOpenNow: Bool? = nil,
pushOnCommit: Bool = false, pushOnCommit: Bool = false,
remoteLocationWarned: Bool = false, remoteLocationWarned: Bool = false,
@@ -164,6 +184,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
self.laneCount = laneCount self.laneCount = laneCount
self.cardCount = cardCount self.cardCount = cardCount
self.windowFrame = windowFrame self.windowFrame = windowFrame
self.cardWindowFrames = cardWindowFrames
self.isOpenNow = isOpenNow self.isOpenNow = isOpenNow
self.pushOnCommit = pushOnCommit self.pushOnCommit = pushOnCommit
self.remoteLocationWarned = remoteLocationWarned self.remoteLocationWarned = remoteLocationWarned
@@ -434,6 +455,28 @@ public final class BoardRegistry {
update(id) { $0.windowFrame = frame } 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 /// 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 /// (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 /// reload changes them"). `BoardStore` calls into this indirectly, through the delegate
+112
View File
@@ -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
}
}
+159
View File
@@ -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)
}
}
+13 -3
View File
@@ -32,8 +32,15 @@ private func makeBoard() throws -> WriterFixture {
} }
private func title(_ fate: CardWindowFate) -> String? { private func title(_ fate: CardWindowFate) -> String? {
guard case let .shows(card) = fate else { return nil } guard case let .shows(placement) = fate else { return nil }
return card.title.value 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 // MARK: - Tests
@@ -42,7 +49,7 @@ private func title(_ fate: CardWindowFate) -> String? {
@Suite("Card window fate") @Suite("Card window fate")
struct CardWindowFateTests { 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 { func aLiveCardShows() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -50,6 +57,9 @@ struct CardWindowFateTests {
let fate = CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) let fate = CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot)
#expect(title(fate) == "Fix login") #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") @Test("A tombstoned card dismisses its window")
+258
View File
@@ -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))
}
}
+3 -1
View File
@@ -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, 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. - **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. - **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. - **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. - **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. - **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. - **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. - **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