Build the style, details, and actions sidebar sections

The sidebar completes: the shared style editor gains a second anchor —
StyleEditorLayout carries the geometry (the popover keeps its settled
268/14/7/8 untouched as the default; the sidebar packs columns to its
width with no inner scroller) while every well, the batch display, the
arrow grammar, and the one applyStyle bracket stay the shared
component's. The card anchor is fixed, not tracking: the target is
this card, and the fate walk retires the window when the card goes.
Details renders every unknown frontmatter key read-only in file order —
Card.document already carried them — showing the author's own bytes
where the raw span is a value and the engine's rendering for block
scalars and empties; reserved enhanced-schema keys are ordinary
unknowns, and no keys means no section. Actions: Delete rides the same
tombstone bytes as Backspace and drop-on-trash through a one-line
seam, says nothing about selection, and lets the fate walk dismiss;
Reveal in Finder resolves through the attachment scope so the two
paths cannot disagree. History reserves its m7 slot without drawing a
header no base board can honor.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 12:22:04 -04:00
parent 46397c740e
commit 40322247e0
10 changed files with 936 additions and 49 deletions
+130
View File
@@ -0,0 +1,130 @@
import SwiftUI
// MARK: - One row
/// One **Details** row: a frontmatter key the app does not own, and the text it carries
/// (05-card-window.md Details).
///
/// `id` is the key because the effective frontmatter view has one entry per key a key written
/// twice reads once, at its winning occurrence (`FrontmatterDocument.parse`, the last-wins rule)
/// so there is nothing here for a duplicate to collide with.
struct CardDetailRow: Identifiable, Equatable, Sendable {
let key: String
let value: String
var id: String { key }
}
// MARK: - The seam
/// What the Details section shows, as a pure function of a parsed `index.md`
/// (05-card-window.md Details).
///
/// ### Which keys
///
/// **Every unknown key, and only unknown keys, in file order.** The set is
/// `FrontmatterDocument.unknownFields` unchanged the schema's eleven names are out, and everything
/// else is in, *including* the reserved enhanced-schema names (`labels`, `assignees`, `due`,
/// `remote`, ), which "are ordinary unknown keys in this version and appear here like any other
/// no special rendering". Order is the document's, which is the file's: 01-storage-format.md
/// preserves key order verbatim and the sidebar honors it, so a hand-written key sits where the hand
/// that wrote it put it.
///
/// Nothing here reaches for a card's folder or re-reads a file: the snapshot's `Card` already
/// carries its whole parsed document, so this section shows exactly what the last reload read, like
/// every other surface in the window.
///
/// ### Which text
///
/// **The rawest honest form the parse can offer, and never an error.** A value written on one line
/// renders as the author's own bytes `rawValue`, which is the span minus the `key:` header, the
/// surrounding whitespace and any trailing comment (a comment belongs to the line, not to the
/// value). Quotes, hex, an ISO timestamp, a flow collection all read exactly as typed, which is the
/// whole point of a section that exists to show what the app did *not* interpret.
///
/// Two shapes have no single-line source text to show, and both fall back to the engine's own
/// reading of the value (`YAMLValue.description`):
///
/// - **A value spanning several lines** a block scalar, a multi-line flow collection. Its raw span
/// carries YAML syntax the value does not (`|`, `>-`, the continuation indent), so the bytes would
/// be a worse answer than the text they encode.
/// - **A value with no text at all** `project:` with nothing after it. The rawest form is the empty
/// string, and a row with a key and a blank beside it reads as a bug rather than as YAML's null.
///
/// Neither branch can throw and neither can fail: the document was parsed before a `Card` existed at
/// all, and every reading below is total over `YAMLValue`. "Exotic YAML shapes display best-effort,
/// never error" is that, exactly.
enum CardDetails {
/// The section's rows, or `[]` when the card carries no unknown keys which is also the whole
/// of the section's visibility rule ("shown only when any exist"), stated where a test can hold
/// it rather than as an `if` in a view.
nonisolated static func rows(of document: FrontmatterDocument) -> [CardDetailRow] {
document.unknownFields.map { CardDetailRow(key: $0.key, value: display(of: $0)) }
}
/// One field's display text see the type's doc comment for the rule and its two fallbacks.
nonisolated static func display(of field: FrontmatterField) -> String {
let raw = field.rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if !raw.isEmpty, !raw.contains(where: \.isNewline) { return raw }
return field.value.description.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
// MARK: - The section
/// The sidebar's **Details** section: read-only key/value rows for every unknown frontmatter key
/// (05-card-window.md Details).
///
/// ### There is no edit affordance, and that is the design
///
/// "Editing is deliberately not offered: the **raw source outlet** is the write path for frontmatter
/// the app doesn't own." So these rows are text selectable, copyable, and nothing else. A field
/// here would have to guess a YAML shape for whatever the user typed, into a key whose meaning the
/// app has no opinion about; E edits the file itself, where the guess is the user's.
///
/// ### Absent, not empty
///
/// The section disappears entirely on a card with no unknown keys which is most cards. Contrast
/// Attachments, which keeps a hint when empty because it advertises a drop surface the user has to
/// be able to find: there is nothing to teach here, and a permanent empty "Details" header would
/// imply the card has details it is failing to show.
struct CardDetailsSection: View {
let rows: [CardDetailRow]
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
var body: some View {
if !rows.isEmpty {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Details")
ForEach(rows) { row in
self.row(row)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Key over value rather than key beside value: the sidebar is 26 characters wide, and a
/// two-column row would give a `project:` overlay four characters to say `lanework` in. The value
/// wraps to as many lines as it needs the sidebar scrolls, and a truncated value in a section
/// whose only job is to show values would be the one thing worse than no section at all.
private func row(_ row: CardDetailRow) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(row.key)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Text(row.value)
.font(.callout)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(row.key), \(row.value)")
}
}
+135
View File
@@ -0,0 +1,135 @@
import AppKit
import SwiftUI
// MARK: - Style
/// The sidebar's **Style** section: "the **embedded style editor** background palette grid (with
/// the leading None well) and curated symbol grid, per 03-board-ui.md Styling Controls. Card
/// styling is discoverable here without a context menu; the same component appears in the board
/// popover and behind Style" (05-card-window.md Style).
///
/// ### How this anchor differs from the other two in one word, geometry
///
/// It hosts `StyleEditorView` itself, not a copy of it: the wells, the batch display, the arrow-key
/// grammar, the read-only disabling, the recents the None well deliberately does not record, and the
/// single `applyStyle` bracket every well's click rides are all the shared component's, identical
/// here. The only thing this anchor supplies beyond a target is a `StyleEditorLayout` the sidebar
/// is narrower than the popover at every text size, so the grids fall in fewer columns and the
/// symbol grid draws whole instead of scrolling inside the sidebar's own scroll view.
///
/// ### The target is fixed, and that is the whole difference in behavior
///
/// The Style popover *tracks*: its target is the selection at the moment the gesture named it,
/// re-resolved against every snapshot, dismissing when it empties (`StyleEditorSession`). This
/// section tracks nothing. Its target is this window's card, always "the two embedded anchors need
/// none of this and get none: the card sidebar dismisses with its card's window, and the board
/// popover's target is the board itself" (`StyleEditorSession`'s own note). Which is why there is no
/// session here to resolve, no popover to dismiss, and no way for a board-side selection change to
/// re-aim the editor a card window is showing: the window's card is the target by construction, and
/// when that card stops existing the window goes with it (`CardWindowFate`).
struct CardStyleSection: View {
let store: BoardStore
let recents: StyleRecents
let cardID: ItemID
/// The live body metric, read here rather than passed in `CardAttachmentsSection`'s pattern,
/// so every section in this sidebar derives its geometry the same way.
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// **What this section styles: this card, and nothing else.**
///
/// A one-line seam because it is a claim worth pinning rather than a computation worth reading:
/// every other anchor derives its target from something that moves (the selection, the board),
/// and the mistake this window could make is inheriting one of those. `.items` of exactly one id
/// also means the editor's own batch machinery is a no-op here one subject, so the display is
/// never mixed and the write bracket holds one file.
nonisolated static func target(forCard id: ItemID) -> StyleTarget {
.items([id])
}
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Style")
StyleEditorView(
store: store,
recents: recents,
target: Self.target(forCard: cardID),
layout: .sidebar(contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize))
)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Actions
/// The sidebar's **Actions** section, at the bottom of the stack (05-card-window.md Actions):
/// **Delete** tombstones the card and **Reveal in Finder** the card's folder.
///
/// ### Delete writes; the window's dismissal is not its business
///
/// The button calls `BoardStore.deleteCard`, which is the tombstone exactly (same write op, same
/// bracket, same stamps). It does not close this window: the card's tombstone rounds back through
/// the watcher and `CardWindowHost.cardWindowFate` takes the window down, which is the same path a
/// delete from the board or from an agent already takes. Dismissing from here as well would be a
/// second rule able to disagree with the first, and 05's own wording is a sequence rather than a
/// pair ("tombstones the card; the window then dismisses itself").
///
/// Recovery is the board's trash quasi-lane, which is why this needs no confirmation: the row is
/// still there to Put Back, and 03-board-ui.md reserves the alert for the purge that isn't
/// recoverable.
///
/// ### Reveal is not edit-shaped
///
/// So it stays enabled under the read-only lock, where Delete does not inspection is a read (04
/// The trash's posture, shared by the trash row's own Reveal). What it reveals comes from
/// `CardAttachments.revealURLs`, the same rule File Reveal in Finder's card-window scope answers
/// through: this button is that rule's card-folder branch by construction, since it is the *card's*
/// action rather than the attachment list's.
struct CardActionsSection: View {
let store: BoardStore
let cardID: ItemID
/// The card's own folder `nil` only where the window has no board to build it from, which is a
/// window on its way out.
let cardFolder: URL?
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
private var revealURLs: [URL] {
CardAttachments.revealURLs(cardFolder: cardFolder, selectedURL: nil, isSectionFocused: false)
}
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Actions")
// Delete above Reveal, which is the order 05 lists them in. Destructive styling, per 05
// the one control in this window that takes the card away. Disabled under the
// read-only lock like every other mutation (02-architecture.md's every-entry-point
// predicate); that is the attachments section's `isEditable`, read from the store
// directly because there is no handle to route it through here and nothing else in this
// section that would want one.
Button(role: .destructive) {
store.deleteCard(cardID)
} label: {
// The width is the *label's*, not the button's: a bordered button sizes to its label,
// so a frame around the button would centre a small pill in a wide row instead of
// filling it. Both rows do it, so the two are one column rather than two widths.
Text("Delete").frame(maxWidth: .infinity)
}
.tint(.red)
.disabled(store.isReadOnly)
Button {
NSWorkspace.shared.activateFileViewerSelecting(revealURLs)
} label: {
Text("Reveal in Finder").frame(maxWidth: .infinity)
}
.disabled(revealURLs.isEmpty)
}
.buttonStyle(.bordered)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
+19
View File
@@ -66,6 +66,16 @@ enum CardWindowMetrics {
columnWidth(characters: sidebarCharacters, bodyPointSize: bodyPointSize)
}
/// What a sidebar *section* actually gets to lay out in: the column minus its two gutters.
///
/// Named because one section needs a number rather than a proposal the embedded style editor's
/// grids are a fixed count of fixed-size wells per row, and the count has to be decided before
/// the layout runs (`StyleEditorLayout.sidebar(contentWidth:)`). Everything else in the sidebar
/// simply fills what it is proposed and never asks.
static func sidebarContentWidth(bodyPointSize: CGFloat) -> CGFloat {
sidebarWidth(bodyPointSize: bodyPointSize) - 2 * gutter(bodyPointSize: bodyPointSize)
}
// MARK: - The body column
/// The narrowest the body column is allowed to get a measure of prose short enough to be a
@@ -79,6 +89,15 @@ enum CardWindowMetrics {
columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize)
}
// MARK: - A sidebar section
/// The gap between a sidebar section's header and its content, and between two rows of it
/// half a gutter, which is the attachment rows' inset and the rendered body's rhythm too, so the
/// whole window is spaced by one unit rather than by three that happen to agree.
static func sidebarRowSpacing(bodyPointSize: CGFloat) -> CGFloat {
previewPadding(bodyPointSize: bodyPointSize)
}
// MARK: - The attachments section
/// An attachment row's thumbnail: a **small** square, one and a half ems on a side
+49 -24
View File
@@ -15,7 +15,8 @@ import UniformTypeIdentifiers
/// 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 sidebar's five sections, which are section *headers* here and nothing more.
/// - the sidebar's History section, whose place in the stack is reserved and whose content waits on
/// a git mode to be honest about (`historySlot`).
///
/// 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
@@ -39,6 +40,20 @@ import UniformTypeIdentifiers
struct CardWindowView: View {
let card: Card
/// The board this card belongs to.
///
/// The one place in this window a whole store is handed to a view rather than a narrow seam, and
/// the sidebar is why: the Style section hosts the **shared** style editor, whose API is
/// store-shaped by design (it reads the target set's current values and writes through the one
/// `applyStyle` bracket every anchor shares), and the Actions section's Delete is the store's own
/// tombstone. Routing either through a closure of this window's own would be a second card-styling
/// or card-deleting path to keep in step with the first exactly what "one component, one
/// behavior" and "exactly the tombstone" forbid.
let store: BoardStore
/// The app-wide quick-style recents the embedded editor feeds (03-board-ui.md Styling
/// Controls) app state, not board state, which is why it arrives beside the store rather than
/// on it.
let recents: StyleRecents
/// The card's folder on disk what relative images and links in the body resolve against
/// (05 Preview). `nil` only where a caller has no board root to build it from.
let cardFolder: URL?
@@ -194,40 +209,50 @@ struct CardWindowView: View {
// MARK: - Attributes sidebar
/// The sidebar's sections, **in 05's settled order**, as headers over empty space.
/// The sidebar's sections, **in 05's settled order**: Attachments, Style, Details, History,
/// Actions.
///
/// 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.
/// Two of the five are conditional, and both conditions are the section's own rather than a rule
/// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys
/// ("shown only when any exist"), and **History** is absent on boards without app-managed git.
/// Everything else in the stack is unconditional, so the composition a user learns on one card is
/// the composition they get on the next.
private var sidebar: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
// 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")
CardStyleSection(store: store, recents: recents, cardID: card.id)
// The snapshot's own document, not a re-read: the loader parsed this file, unknown
// keys and their order included, and `Card` has carried it since (`BoardModel`).
CardDetailsSection(rows: CardDetails.rows(of: card.document))
historySlot
CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder)
}
.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") the same header the Attachments section fills in for real, so
/// the four still-empty ones cannot drift from it.
private func section(_ title: String) -> some View {
CardSidebarSectionHeader(title: title)
.accessibilityElement(children: .combine)
/// **The History section's reserved place in the stack** between Details and Actions, 05's
/// order (05 History: "the card's commit trail, read-only newest first semantic subject,
/// relative date, author").
///
/// Nothing is drawn yet, deliberately: the section is conditional on a git mode that does not
/// exist here, so a header over empty space would claim a commit trail on every board and on
/// the boards where it is *absent* by design (mode none, repo-nested) it would be claiming one
/// that can never arrive. What the slot reserves is the **position**, so filling it in moves
/// nothing above or below it.
///
// m7-git: the trail itself, plus the two rules that come with it absence on boards without
// app-managed git (the same honesty rule as the board popover's git section, 06-history-undo.md)
// and View History, which focuses this section (11-command-nexus.md).
@ViewBuilder
private var historySlot: some View {
EmptyView()
}
}