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
+13 -3
View File
@@ -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")