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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user