Files
lanework/Kanban/UI/Card/CardWindowView.swift
T
rzen 1e65b7c986 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
2026-07-28 09:27:01 -04:00

160 lines
7.4 KiB
Swift

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)
}
}