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
/// 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 }
+5
View File
@@ -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()
+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
+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)
}
}