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
+43
View File
@@ -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