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