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
+107 -22
View File
@@ -11,6 +11,10 @@ import SwiftUI
/// what lets "one component, one behavior, three anchors" be a fact about the code rather than a
/// promise. The Style popover's *lifecycle* lives elsewhere for the same reason: it is a reload
/// rule, and it belongs with the other reload rules (`StyleEditorSession`, `TransientBoardState`).
///
/// The one thing here that *names* an anchor is `StyleEditorLayout`, and it names only geometry: a
/// popover is a window this app sizes and a sidebar section is a column the window sizes, so the two
/// cannot share a frame. Nothing behavioral hangs off it see its own doc comment.
// MARK: - The write funnel
@@ -89,6 +93,77 @@ enum CuratedSymbols {
static var available: [String] { all.filter(ItemSymbol.exists) }
}
// MARK: - The anchor's chrome
/// Everything about the editor that is the **anchor's** business rather than the editor's: how wide
/// it is, what padding it brings, how many wells fall in a row, and whether its symbol grid scrolls.
///
/// **It exists so "one component, one behavior, another anchor" survives an anchor that is not a
/// popover** (05-card-window.md Style: the card sidebar embeds this same editor). A popover is a
/// window the app sizes; a sidebar section is a column the window sizes and the 268-point frame
/// that makes the first one narrow enough to sit beside a card would overflow the second by 70
/// points. Nothing about *behavior* is in here: every well, every write, the batch display and the
/// keyboard grammar are the editor's, identical at every anchor. Only the geometry moves.
struct StyleEditorLayout: Equatable {
/// One well's side, and the gap between two the numbers the grids are laid out on, named once
/// so the fit rule below and the wells themselves cannot drift apart.
static let wellSide: CGFloat = 20
static let wellSpacing: CGFloat = 6
/// A fixed width, or `nil` to take whatever the anchor proposes.
var width: CGFloat?
/// The editor's own inset. Zero where the anchor already insets its column.
var padding: CGFloat
var backgroundColumns: Int
var symbolColumns: Int
/// How tall the symbol grid may grow before it scrolls inside itself, or `nil` for "never"
/// the grid then draws whole and the anchor scrolls it.
var symbolGridMaximumHeight: CGFloat?
/// The Style popover and the board popover's styling area: a fixed frame, its own padding, and
/// a symbol grid that scrolls within it.
///
/// Thirteen background wells (None + the twelve) fall as 7 + 6, which keeps the popover narrow
/// enough to sit beside a card without covering the lane it came from; the symbol grid's cap is
/// eight rows or so enough that it reads as a set rather than as a strip, short enough that the
/// popover fits beside a card on a laptop screen.
static let popover = StyleEditorLayout(
width: 268,
padding: 14,
backgroundColumns: 7,
symbolColumns: 8,
symbolGridMaximumHeight: 168
)
/// The card window's sidebar section (05-card-window.md Style).
///
/// - **No width and no padding of its own**: the sidebar's width is `CardWindowMetrics`' one
/// decision and its gutter is already applied to the whole section stack, so an editor with an
/// opinion here would either overflow the column or inset twice.
/// - **As many wells per row as the column holds**, rather than the popover's 7 and 8 the
/// sidebar is narrower than the popover at every text size, and a grid wider than its column is
/// a grid with wells the pointer cannot reach.
/// - **The symbol grid does not scroll.** The sidebar is already a scroll view, and a scroll view
/// inside a scroll view is a scroll view that fights (`CardWindowView`'s rule, for its reason).
static func sidebar(contentWidth: CGFloat) -> StyleEditorLayout {
let columns = columns(fitting: contentWidth)
return StyleEditorLayout(
width: nil,
padding: 0,
backgroundColumns: columns,
symbolColumns: columns,
symbolGridMaximumHeight: nil
)
}
/// How many wells fit across `width` `n` wells and `n - 1` gaps, floored, and never less than
/// one. Pure, and the whole of "the grid never overflows the column it was given".
static func columns(fitting width: CGFloat) -> Int {
max(1, Int((width + wellSpacing) / (wellSide + wellSpacing)))
}
}
// MARK: - The editor
/// The style editor: a background section and a symbol section, each a leading "no value" well
@@ -112,11 +187,9 @@ struct StyleEditorView: View {
let store: BoardStore
let recents: StyleRecents
let target: StyleTarget
/// Wells per row. Thirteen background wells (None + the twelve) fall as 7 + 6, which keeps the
/// popover narrow enough to sit beside a card without covering the lane it came from.
private let backgroundColumns = 7
private let symbolColumns = 8
/// The anchor's geometry, and nothing else (`StyleEditorLayout`). Defaulted to the popover's, so
/// the two anchors that were here first say nothing about it.
var layout: StyleEditorLayout = .popover
var body: some View {
let subjects = store.styleSubjects(of: target)
@@ -129,8 +202,8 @@ struct StyleEditorView: View {
Divider()
symbolSection(icon)
}
.padding(14)
.frame(width: 268)
.padding(layout.padding)
.frame(width: layout.width)
// The read-only lock and the focused-editor rule disable every mutating surface, not only
// the menu items (02-architecture.md § The lock's scope) an editor whose wells would be
// refused should not look available. The popover stays *open*: the lock is a condition the
@@ -163,7 +236,7 @@ struct StyleEditorView: View {
sectionHeader("Background", current: backgroundCurrent(state))
StyleWellGrid(
wells: backgroundWells(state),
columns: backgroundColumns,
columns: layout.backgroundColumns,
apply: { change in
StyleCommand.apply(background: change, to: target, in: store, recents: recents)
}
@@ -204,18 +277,27 @@ struct StyleEditorView: View {
let fallback = ItemSymbol.default(for: level)
return VStack(alignment: .leading, spacing: 8) {
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback))
ScrollView(.vertical) {
StyleWellGrid(
wells: symbolWells(state, fallback: fallback),
columns: symbolColumns,
apply: { change in
StyleCommand.apply(icon: change, to: target, in: store, recents: recents)
}
)
symbolGrid(state, fallback: fallback)
}
}
/// The curated grid, scrolling within its own cap or drawn whole the anchor's call
/// (`StyleEditorLayout.symbolGridMaximumHeight`), and the one shape difference between the
/// popover and the card sidebar.
@ViewBuilder
private func symbolGrid(_ state: StyleFieldState, fallback: String) -> some View {
let grid = StyleWellGrid(
wells: symbolWells(state, fallback: fallback),
columns: layout.symbolColumns,
apply: { change in
StyleCommand.apply(icon: change, to: target, in: store, recents: recents)
}
// Eight rows or so before it scrolls: enough that the grid reads as a set rather than as
// a strip, short enough that the popover fits beside a card on a laptop screen.
.frame(maxHeight: 168)
)
if let maximumHeight = layout.symbolGridMaximumHeight {
ScrollView(.vertical) { grid }
.frame(maxHeight: maximumHeight)
} else {
grid
}
}
@@ -316,7 +398,7 @@ private struct StyleWellFace: View {
}
let face: Face
var size: CGFloat = 20
var size: CGFloat = StyleEditorLayout.wellSide
var body: some View {
switch face {
@@ -380,8 +462,11 @@ private struct StyleWellGrid: View {
var body: some View {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(minimum: 20), spacing: 6), count: columns),
spacing: 6
columns: Array(
repeating: GridItem(.flexible(minimum: StyleEditorLayout.wellSide), spacing: StyleEditorLayout.wellSpacing),
count: columns
),
spacing: StyleEditorLayout.wellSpacing
) {
ForEach(wells) { well in
Button {