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:
@@ -272,6 +272,11 @@ struct CardWindowHost: View {
|
|||||||
if case let .open(store) = phase, let placement {
|
if case let .open(store) = phase, let placement {
|
||||||
CardWindowView(
|
CardWindowView(
|
||||||
card: placement.card,
|
card: placement.card,
|
||||||
|
store: store,
|
||||||
|
// The app's list, not the board's: the quick-style recents the sidebar's embedded
|
||||||
|
// editor feeds are app-wide state (02-architecture.md § Per-board app state), so
|
||||||
|
// they come from the model every window shares rather than from this board's store.
|
||||||
|
recents: appModel.styleRecents,
|
||||||
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
|
cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
|
||||||
bodyPresentation: bodyPresentation,
|
bodyPresentation: bodyPresentation,
|
||||||
bodySession: session.body,
|
bodySession: session.body,
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ struct FindSteppingCommands: View {
|
|||||||
/// did not move, the validation and the action filled in — and the pair also carries the clause that
|
/// did not move, the validation and the action filled in — and the pair also carries the clause that
|
||||||
/// joins them, "Edit Body disables while Raw Source is active" (05-card-window.md).
|
/// joins them, "Edit Body disables while Raw Source is active" (05-card-window.md).
|
||||||
///
|
///
|
||||||
// m6-card-sidebar: History is a plain command that focuses the sidebar's History section, and
|
// m7-git: History is a plain command that focuses the sidebar's History section, and disables
|
||||||
// disables outright on mode `none` / repo-nested boards once that section exists (05-card-window.md,
|
// outright on mode `none` / repo-nested boards once that section exists (05-card-window.md,
|
||||||
// 07-sync-collab.md). It remains unconditionally disabled here — that surface does not exist yet.
|
// 07-sync-collab.md). It remains unconditionally disabled here — the sidebar reserves the section's
|
||||||
|
// place (`CardWindowView.historySlot`) but draws nothing, so there is still no surface to focus.
|
||||||
struct CardViewCommands: View {
|
struct CardViewCommands: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
EditBodyCommand()
|
EditBodyCommand()
|
||||||
|
|||||||
@@ -2462,6 +2462,34 @@ public final class BoardStore {
|
|||||||
tombstone(folders)
|
tombstone(folders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — tombstones the
|
||||||
|
/// card … the window then dismisses itself").
|
||||||
|
///
|
||||||
|
/// The write is `tombstone(_:)`, so a card deleted from its own window is byte-indistinguishable
|
||||||
|
/// from one deleted with ⌫ on the board or dropped on the trash lane — one write op, one bracket,
|
||||||
|
/// one set of stamps. What differs is the same thing that differs for the drag, and for its
|
||||||
|
/// reason: **it says nothing about the selection.** ⌫ moves the board's selection to the deleted
|
||||||
|
/// card's successor sibling because the rule exists to make a repeated keystroke walk down a lane;
|
||||||
|
/// a button in another window has no such continuation, and the card it deletes need not be
|
||||||
|
/// selected on the board at all — picking a successor here would re-point a selection that never
|
||||||
|
/// lost anything. The ordinary reload does the rest: a live-side selection ejects a member that
|
||||||
|
/// flips to tombstoned, as the vanish it is.
|
||||||
|
///
|
||||||
|
/// **It does not dismiss the window either**, and must not: the window's dismissal is a *fate*
|
||||||
|
/// re-derived from every snapshot (`CardWindowHost.cardWindowFate`), so the tombstone this writes
|
||||||
|
/// comes back through the watcher and the fate walk takes the window down — the same path an
|
||||||
|
/// agent's or another window's delete takes. A second dismissal from here would be a second rule
|
||||||
|
/// able to disagree with the first.
|
||||||
|
///
|
||||||
|
/// A vanished or already-tombstoned card resolves to no path and writes nothing, `delete(_:)`'s
|
||||||
|
/// rule; liveness is ancestor-walked, so a card under a tombstoned lane is gone too — and its
|
||||||
|
/// window is already dismissing.
|
||||||
|
public func deleteCard(_ id: ItemID) {
|
||||||
|
let folders = TrashModel.paths(of: [id], on: .live, in: snapshot).map { $0.folder(under: rootURL) }
|
||||||
|
guard !folders.isEmpty else { return }
|
||||||
|
tombstone(folders)
|
||||||
|
}
|
||||||
|
|
||||||
/// The tombstone write itself — **one `performWrite` bracket, whatever the set's size and
|
/// The tombstone write itself — **one `performWrite` bracket, whatever the set's size and
|
||||||
/// whichever gesture asked** (DRAG-REORDER.md § The drop commits; the style batch's rule).
|
/// whichever gesture asked** (DRAG-REORDER.md § The drop commits; the style batch's rule).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,16 @@ enum CardWindowMetrics {
|
|||||||
columnWidth(characters: sidebarCharacters, bodyPointSize: bodyPointSize)
|
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
|
// MARK: - The body column
|
||||||
|
|
||||||
/// The narrowest the body column is allowed to get — a measure of prose short enough to be a
|
/// 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)
|
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
|
// MARK: - The attachments section
|
||||||
|
|
||||||
/// An attachment row's thumbnail: a **small** square, one and a half ems on a side
|
/// An attachment row's thumbnail: a **small** square, one and a half ems on a side
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import UniformTypeIdentifiers
|
|||||||
/// reads or writes beyond that is later work and is marked where it lands:
|
/// 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 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
|
/// 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
|
/// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in
|
||||||
@@ -39,6 +40,20 @@ import UniformTypeIdentifiers
|
|||||||
struct CardWindowView: View {
|
struct CardWindowView: View {
|
||||||
|
|
||||||
let card: Card
|
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
|
/// 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.
|
/// (05 ▸ Preview). `nil` only where a caller has no board root to build it from.
|
||||||
let cardFolder: URL?
|
let cardFolder: URL?
|
||||||
@@ -194,40 +209,50 @@ struct CardWindowView: View {
|
|||||||
|
|
||||||
// MARK: - Attributes sidebar
|
// 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
|
/// Two of the five are conditional, and both conditions are the section's own rather than a rule
|
||||||
/// carries unknown frontmatter keys, and History is absent on boards without app-managed git —
|
/// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys
|
||||||
/// and the shell shows them unconditionally because it has neither the key inventory nor a git
|
/// ("shown only when any exist"), and **History** is absent on boards without app-managed git.
|
||||||
/// mode to consult yet. That is the one place these placeholders are not yet the final
|
/// Everything else in the stack is unconditional, so the composition a user learns on one card is
|
||||||
/// composition, and it resolves when the sections do.
|
/// the composition they get on the next.
|
||||||
private var sidebar: some View {
|
private var sidebar: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
|
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
|
||||||
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
|
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
|
||||||
// m6-card-sidebar: the embedded style editor — the same component the board popover
|
|
||||||
// and Style… already host (`StyleEditor`).
|
CardStyleSection(store: store, recents: recents, cardID: card.id)
|
||||||
section("Style")
|
|
||||||
// m6-card-sidebar: read-only key/value rows for every unknown frontmatter key, in
|
// The snapshot's own document, not a re-read: the loader parsed this file, unknown
|
||||||
// file order.
|
// keys and their order included, and `Card` has carried it since (`BoardModel`).
|
||||||
section("Details")
|
CardDetailsSection(rows: CardDetails.rows(of: card.document))
|
||||||
// m7-git: the card's commit trail, read-only; absent on mode none / repo-nested.
|
|
||||||
section("History")
|
historySlot
|
||||||
// m6-card-sidebar: Delete (tombstones, the window then dismisses itself) and Reveal
|
|
||||||
// in Finder.
|
CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder)
|
||||||
section("Actions")
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
.padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stacked small-caps header over the space its section will occupy (05: "Stacked sections
|
/// **The History section's reserved place in the stack** — between Details and Actions, 05's
|
||||||
/// under small-caps headers") — the same header the Attachments section fills in for real, so
|
/// order (05 ▸ History: "the card's commit trail, read-only … newest first — semantic subject,
|
||||||
/// the four still-empty ones cannot drift from it.
|
/// relative date, author").
|
||||||
private func section(_ title: String) -> some View {
|
///
|
||||||
CardSidebarSectionHeader(title: title)
|
/// Nothing is drawn yet, deliberately: the section is conditional on a git mode that does not
|
||||||
.accessibilityElement(children: .combine)
|
/// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+103
-18
@@ -11,6 +11,10 @@ import SwiftUI
|
|||||||
/// what lets "one component, one behavior, three anchors" be a fact about the code rather than a
|
/// 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
|
/// 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`).
|
/// 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
|
// MARK: - The write funnel
|
||||||
|
|
||||||
@@ -89,6 +93,77 @@ enum CuratedSymbols {
|
|||||||
static var available: [String] { all.filter(ItemSymbol.exists) }
|
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
|
// MARK: - The editor
|
||||||
|
|
||||||
/// The style editor: a background section and a symbol section, each a leading "no value" well
|
/// 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 store: BoardStore
|
||||||
let recents: StyleRecents
|
let recents: StyleRecents
|
||||||
let target: StyleTarget
|
let target: StyleTarget
|
||||||
|
/// The anchor's geometry, and nothing else (`StyleEditorLayout`). Defaulted to the popover's, so
|
||||||
/// Wells per row. Thirteen background wells (None + the twelve) fall as 7 + 6, which keeps the
|
/// the two anchors that were here first say nothing about it.
|
||||||
/// popover narrow enough to sit beside a card without covering the lane it came from.
|
var layout: StyleEditorLayout = .popover
|
||||||
private let backgroundColumns = 7
|
|
||||||
private let symbolColumns = 8
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let subjects = store.styleSubjects(of: target)
|
let subjects = store.styleSubjects(of: target)
|
||||||
@@ -129,8 +202,8 @@ struct StyleEditorView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
symbolSection(icon)
|
symbolSection(icon)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(layout.padding)
|
||||||
.frame(width: 268)
|
.frame(width: layout.width)
|
||||||
// The read-only lock and the focused-editor rule disable every mutating surface, not only
|
// 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
|
// 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
|
// 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))
|
sectionHeader("Background", current: backgroundCurrent(state))
|
||||||
StyleWellGrid(
|
StyleWellGrid(
|
||||||
wells: backgroundWells(state),
|
wells: backgroundWells(state),
|
||||||
columns: backgroundColumns,
|
columns: layout.backgroundColumns,
|
||||||
apply: { change in
|
apply: { change in
|
||||||
StyleCommand.apply(background: change, to: target, in: store, recents: recents)
|
StyleCommand.apply(background: change, to: target, in: store, recents: recents)
|
||||||
}
|
}
|
||||||
@@ -204,18 +277,27 @@ struct StyleEditorView: View {
|
|||||||
let fallback = ItemSymbol.default(for: level)
|
let fallback = ItemSymbol.default(for: level)
|
||||||
return VStack(alignment: .leading, spacing: 8) {
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback))
|
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback))
|
||||||
ScrollView(.vertical) {
|
symbolGrid(state, fallback: fallback)
|
||||||
StyleWellGrid(
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
wells: symbolWells(state, fallback: fallback),
|
||||||
columns: symbolColumns,
|
columns: layout.symbolColumns,
|
||||||
apply: { change in
|
apply: { change in
|
||||||
StyleCommand.apply(icon: change, to: target, in: store, recents: recents)
|
StyleCommand.apply(icon: change, to: target, in: store, recents: recents)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
if let maximumHeight = layout.symbolGridMaximumHeight {
|
||||||
// Eight rows or so before it scrolls: enough that the grid reads as a set rather than as
|
ScrollView(.vertical) { grid }
|
||||||
// a strip, short enough that the popover fits beside a card on a laptop screen.
|
.frame(maxHeight: maximumHeight)
|
||||||
.frame(maxHeight: 168)
|
} else {
|
||||||
|
grid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +398,7 @@ private struct StyleWellFace: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let face: Face
|
let face: Face
|
||||||
var size: CGFloat = 20
|
var size: CGFloat = StyleEditorLayout.wellSide
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch face {
|
switch face {
|
||||||
@@ -380,8 +462,11 @@ private struct StyleWellGrid: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LazyVGrid(
|
LazyVGrid(
|
||||||
columns: Array(repeating: GridItem(.flexible(minimum: 20), spacing: 6), count: columns),
|
columns: Array(
|
||||||
spacing: 6
|
repeating: GridItem(.flexible(minimum: StyleEditorLayout.wellSide), spacing: StyleEditorLayout.wellSpacing),
|
||||||
|
count: columns
|
||||||
|
),
|
||||||
|
spacing: StyleEditorLayout.wellSpacing
|
||||||
) {
|
) {
|
||||||
ForEach(wells) { well in
|
ForEach(wells) { well in
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// The card window's sidebar sections, reduced to the rules a unit test can hold (05-card-window.md
|
||||||
|
/// ▸ The attributes sidebar).
|
||||||
|
///
|
||||||
|
/// The views are not the point and are not tested. What is tested is the three seams under them,
|
||||||
|
/// each of which fails *silently* — the failure mode this file exists for:
|
||||||
|
///
|
||||||
|
/// - **Details** shows frontmatter the app deliberately does not understand, so nothing downstream
|
||||||
|
/// can notice when a key goes missing, arrives out of order, or renders as a YAML indicator. The
|
||||||
|
/// only check on it is a test that reads a file and says what the rows must be.
|
||||||
|
/// - **Style** embeds a shared, selection-aware component in a window that has no selection. An
|
||||||
|
/// anchor wired to the wrong target would look completely normal until it restyled something else.
|
||||||
|
/// - **The sidebar's geometry** is a grid of fixed-size wells in a column sized from font metrics:
|
||||||
|
/// at the wrong column count the last well in each row is simply unreachable, at no text size
|
||||||
|
/// anyone tests by eye.
|
||||||
|
|
||||||
|
// MARK: - Details ▸ which keys
|
||||||
|
|
||||||
|
@Suite("Card details ▸ keys")
|
||||||
|
struct CardDetailsKeyTests {
|
||||||
|
|
||||||
|
/// One card carrying every kind of key at once: the schema's own, an agent overlay, four
|
||||||
|
/// reserved enhanced-schema names, and a key written twice.
|
||||||
|
private static let card = """
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: Fix login
|
||||||
|
order: 1024
|
||||||
|
project: lanework # agent overlay
|
||||||
|
labels: [a, b, c]
|
||||||
|
assignees:
|
||||||
|
- ada
|
||||||
|
- grace
|
||||||
|
due: 2026-08-01
|
||||||
|
remote: {name: origin, branch: main}
|
||||||
|
created: 2026-01-01T09:00:00Z
|
||||||
|
modified: 2026-02-02T09:00:00Z
|
||||||
|
modified-by: claude
|
||||||
|
background: mint
|
||||||
|
icon: flag
|
||||||
|
iconColor: carnation
|
||||||
|
project: overlay-rewritten
|
||||||
|
---
|
||||||
|
Body text.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("Every unknown key appears, in file order")
|
||||||
|
func unknownKeysInFileOrder() throws {
|
||||||
|
let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card))
|
||||||
|
|
||||||
|
// File order, verbatim — 01-storage-format.md preserves it and the sidebar honors it. The
|
||||||
|
// twice-written `project` reads once, and it reads where its *winning* occurrence sits: the
|
||||||
|
// effective view a duplicate collapses to (`FrontmatterDocument.parse`, last-wins), which is
|
||||||
|
// also the order the file itself takes the moment anything rewrites that key.
|
||||||
|
#expect(rows.map(\.key) == ["labels", "assignees", "due", "remote", "project"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Reserved enhanced-schema keys are ordinary unknown keys in this version")
|
||||||
|
func reservedKeysAreShown() throws {
|
||||||
|
let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card))
|
||||||
|
|
||||||
|
// "`labels`, `assignees`, `due`, `remote`, … are ordinary unknown keys in this version and
|
||||||
|
// appear here like any other — no special rendering" (05 ▸ Details). The day they gain
|
||||||
|
// meaning they leave this section for a control; until then, hiding them would hide data the
|
||||||
|
// file plainly has.
|
||||||
|
for reserved in ["labels", "assignees", "due", "remote"] {
|
||||||
|
#expect(rows.contains { $0.key == reserved }, "\(reserved) belongs in Details")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every schema-owned key is excluded — including the ones the sidebar shows elsewhere")
|
||||||
|
func schemaKeysAreExcluded() throws {
|
||||||
|
let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card))
|
||||||
|
let keys = Set(rows.map(\.key))
|
||||||
|
|
||||||
|
// The eleven the app owns. `title`, `created`/`modified`/`modified-by` and the three style
|
||||||
|
// keys have their own surfaces in this very window (the title field, the date line, the Style
|
||||||
|
// section), and `schema`/`order`/`width`/`deleted` are structure — a Details row for any of
|
||||||
|
// them would be the same fact stated twice, in a section whose whole premise is "keys the app
|
||||||
|
// does not own".
|
||||||
|
#expect(keys.isDisjoint(with: FrontmatterKeys.schemaOwned))
|
||||||
|
#expect(keys == ["labels", "assignees", "due", "remote", "project"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A card with no unknown keys has no section at all")
|
||||||
|
func theSectionDisappearsWithNothingToShow() throws {
|
||||||
|
// The visibility rule, as the seam states it: "shown only when any exist" (05 ▸ Details).
|
||||||
|
// Empty rows are the view's whole condition, so this is that condition.
|
||||||
|
let plain = "---\nschema: 1\ntitle: Plain\norder: 1024\n---\nBody.\n"
|
||||||
|
#expect(CardDetails.rows(of: try FrontmatterDocument.parse(plain)).isEmpty)
|
||||||
|
|
||||||
|
// Style keys and stamps are not "details" either — a styled, stamped card still has none.
|
||||||
|
let styled = """
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: Styled
|
||||||
|
order: 1024
|
||||||
|
background: mint
|
||||||
|
icon: flag
|
||||||
|
iconColor: carnation
|
||||||
|
created: 2026-01-01T09:00:00Z
|
||||||
|
modified: 2026-02-02T09:00:00Z
|
||||||
|
modified-by: claude
|
||||||
|
width: 2
|
||||||
|
deleted: 2026-03-03T09:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
"""
|
||||||
|
#expect(CardDetails.rows(of: try FrontmatterDocument.parse(styled)).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The rows come off the snapshot's own card, unknown keys and order intact")
|
||||||
|
func theSnapshotCarriesTheKeys() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Self.card)
|
||||||
|
|
||||||
|
// Nothing was added to the loader for this section: `Card.document` is the whole parsed
|
||||||
|
// `index.md` and has been since the model existed, so the section reads what the last reload
|
||||||
|
// read — no second parse, no directory walk, no freshness rule of its own.
|
||||||
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
let card = try #require(model.lanes.first?.cards.first)
|
||||||
|
|
||||||
|
#expect(CardDetails.rows(of: card.document).map(\.key) == ["labels", "assignees", "due", "remote", "project"])
|
||||||
|
#expect(CardDetails.rows(of: card.document).first?.value == "[a, b, c]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Details ▸ which text
|
||||||
|
|
||||||
|
@Suite("Card details ▸ values")
|
||||||
|
struct CardDetailsValueTests {
|
||||||
|
|
||||||
|
private func rows(_ frontmatter: String) throws -> [String: String] {
|
||||||
|
let text = "---\nschema: 1\norder: 1024\n\(frontmatter)---\nBody.\n"
|
||||||
|
return Dictionary(uniqueKeysWithValues: CardDetails.rows(of: try FrontmatterDocument.parse(text))
|
||||||
|
.map { ($0.key, $0.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A one-line value renders as the author's own bytes")
|
||||||
|
func singleLineValuesAreVerbatim() throws {
|
||||||
|
let values = try rows("""
|
||||||
|
plain: lanework
|
||||||
|
flow-map: {name: origin, branch: main}
|
||||||
|
flow-seq: [a, b, c]
|
||||||
|
hex: "#FF8800"
|
||||||
|
quoted: "a # b"
|
||||||
|
commented: kept # this comment is the line's, not the value's
|
||||||
|
stamp: 2026-08-01T09:00:00Z
|
||||||
|
number: 2048
|
||||||
|
|
||||||
|
""")
|
||||||
|
|
||||||
|
// The rawest honest form: quotes, braces, brackets, a hash inside a quoted scalar — all of it
|
||||||
|
// exactly as typed, because a section that exists to show what the app did *not* interpret
|
||||||
|
// must not quietly re-serialize it.
|
||||||
|
#expect(values["plain"] == "lanework")
|
||||||
|
#expect(values["flow-map"] == "{name: origin, branch: main}")
|
||||||
|
#expect(values["flow-seq"] == "[a, b, c]")
|
||||||
|
#expect(values["hex"] == "\"#FF8800\"")
|
||||||
|
#expect(values["quoted"] == "\"a # b\"")
|
||||||
|
#expect(values["stamp"] == "2026-08-01T09:00:00Z")
|
||||||
|
#expect(values["number"] == "2048")
|
||||||
|
// A trailing comment is the *line's*, and dropping it is the engine's own read-side rule —
|
||||||
|
// the bytes on disk keep it.
|
||||||
|
#expect(values["commented"] == "kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A value spanning several lines reads as its text, not as its YAML syntax")
|
||||||
|
func multiLineValuesFallBackToTheParsedReading() throws {
|
||||||
|
let values = try rows("""
|
||||||
|
notes: |
|
||||||
|
first line
|
||||||
|
second line
|
||||||
|
folded: >-
|
||||||
|
wrapped
|
||||||
|
prose
|
||||||
|
block-seq:
|
||||||
|
- ada
|
||||||
|
- grace
|
||||||
|
block-map:
|
||||||
|
name: origin
|
||||||
|
branch: main
|
||||||
|
wrapped-flow: [one,
|
||||||
|
two]
|
||||||
|
|
||||||
|
""")
|
||||||
|
|
||||||
|
// The raw span of a block scalar carries `|`, `>-` and the continuation indent — YAML syntax
|
||||||
|
// the *value* does not have. So these fall back to the engine's own reading, which is the
|
||||||
|
// text the author meant rather than the punctuation that encoded it.
|
||||||
|
#expect(values["notes"] == "first line\nsecond line")
|
||||||
|
#expect(values["folded"] == "wrapped prose")
|
||||||
|
#expect(values["block-seq"] == "[ada, grace]")
|
||||||
|
#expect(values["block-map"] == "{name: origin, branch: main}")
|
||||||
|
#expect(values["wrapped-flow"] == "[one, two]")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A key with no value reads as null rather than as a blank")
|
||||||
|
func emptyValuesReadAsNull() throws {
|
||||||
|
let values = try rows("""
|
||||||
|
sphere:
|
||||||
|
explicit: null
|
||||||
|
tilde: ~
|
||||||
|
empty-string: ""
|
||||||
|
only-a-comment: # nothing but this
|
||||||
|
space-after: " "
|
||||||
|
|
||||||
|
""")
|
||||||
|
|
||||||
|
// A row showing a key and nothing beside it reads as a bug in the app; YAML's own word for
|
||||||
|
// the value is the honest thing to draw. An explicitly *empty string* is a different value
|
||||||
|
// and keeps its own bytes.
|
||||||
|
#expect(values["sphere"] == "null")
|
||||||
|
#expect(values["explicit"] == "null")
|
||||||
|
#expect(values["tilde"] == "~")
|
||||||
|
#expect(values["only-a-comment"] == "null")
|
||||||
|
#expect(values["empty-string"] == "\"\"")
|
||||||
|
#expect(values["space-after"] == "\" \"")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A duplicated key reads once, with the winning occurrence's value")
|
||||||
|
func duplicateKeysCollapseLastWins() throws {
|
||||||
|
let document = try FrontmatterDocument.parse("""
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
order: 1024
|
||||||
|
project: first
|
||||||
|
sphere: home
|
||||||
|
project: second
|
||||||
|
---
|
||||||
|
Body.
|
||||||
|
|
||||||
|
""")
|
||||||
|
let rows = CardDetails.rows(of: document)
|
||||||
|
|
||||||
|
// 01-storage-format.md's deliberate divergence from strict YAML, surfaced: the section shows
|
||||||
|
// what the app *reads*, and the app reads the last occurrence. Two rows for one key would say
|
||||||
|
// the card has a value it does not have.
|
||||||
|
#expect(rows.map(\.key) == ["sphere", "project"])
|
||||||
|
#expect(rows.map(\.value) == ["home", "second"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Frontmatter the editor cannot address still renders — readable-but-uneditable")
|
||||||
|
func anUneditableDocumentStillShowsItsKeys() throws {
|
||||||
|
// A whole-frontmatter flow mapping: no key has a line of its own, so `rawValue` has no span
|
||||||
|
// to read and every value falls back to the parsed reading. The document refuses *writes*
|
||||||
|
// (`uneditableShape`) — this section only reads, so it shows the keys like any other card's.
|
||||||
|
let document = try FrontmatterDocument.parse("""
|
||||||
|
---
|
||||||
|
{schema: 1, order: 1024, project: lanework, labels: [a, b]}
|
||||||
|
---
|
||||||
|
Body.
|
||||||
|
|
||||||
|
""")
|
||||||
|
#expect(document.uneditableShape != nil)
|
||||||
|
#expect(CardDetails.rows(of: document).map(\.key) == ["project", "labels"])
|
||||||
|
#expect(CardDetails.rows(of: document).map(\.value) == ["lanework", "[a, b]"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nothing a parsed document can hold makes a row throw or vanish")
|
||||||
|
func exoticShapesAreLenient() throws {
|
||||||
|
// "Values render as plain text, leniently — exotic YAML shapes display best-effort, never
|
||||||
|
// error" (05 ▸ Details). Every shape here has *some* row, and none of them is empty: an
|
||||||
|
// unreadable value must still say that the key is there, since the raw source outlet is the
|
||||||
|
// only way to fix it and the user has to know to go there.
|
||||||
|
let values = try rows("""
|
||||||
|
nested: {a: {b: [1, 2, {c: d}]}}
|
||||||
|
booleans: [true, false, yes, no]
|
||||||
|
unicode: "日本語 — ✂ \\u00e9"
|
||||||
|
anchored: &a value
|
||||||
|
aliased: *a
|
||||||
|
colon-in-value: "key: not a key"
|
||||||
|
tabbed: "a\\tb"
|
||||||
|
big: 123456789012345678901234567890
|
||||||
|
exponent: 1.2e+34
|
||||||
|
infinity: .inf
|
||||||
|
not-a-number: .nan
|
||||||
|
dashes: "- not a list"
|
||||||
|
|
||||||
|
""")
|
||||||
|
|
||||||
|
#expect(values.count == 12)
|
||||||
|
for (key, value) in values {
|
||||||
|
#expect(!value.isEmpty, "\(key) rendered as nothing at all")
|
||||||
|
}
|
||||||
|
// An alias resolves to `.null` in the snapshot's value view, but its *source span* is what
|
||||||
|
// the row shows — so an aliased value states the alias rather than lying about being empty.
|
||||||
|
#expect(values["aliased"] == "*a")
|
||||||
|
#expect(values["infinity"] == ".inf")
|
||||||
|
#expect(values["not-a-number"] == ".nan")
|
||||||
|
#expect(values["colon-in-value"] == "\"key: not a key\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Style ▸ the anchor
|
||||||
|
|
||||||
|
@Suite("Card sidebar ▸ style anchoring")
|
||||||
|
struct CardStyleAnchorTests {
|
||||||
|
|
||||||
|
@Test("The section's target is this window's card, and only this window's card")
|
||||||
|
func theTargetIsTheWindowsCard() {
|
||||||
|
let card = ItemID(rawValue: Ident.card1)
|
||||||
|
|
||||||
|
// The card window has no selection and inherits none: "the two embedded anchors need none of
|
||||||
|
// this and get none — the card sidebar dismisses with its card's window" (`StyleEditorSession`).
|
||||||
|
// A target derived from anything that moves is the one way this anchor could restyle
|
||||||
|
// something the user is not looking at.
|
||||||
|
#expect(CardStyleSection.target(forCard: card) == .items([card]))
|
||||||
|
|
||||||
|
// Never the board — the fallback the *selection-aware* anchor takes with nothing selected,
|
||||||
|
// and the one this anchor must never reach: a card window styling the whole board would
|
||||||
|
// repaint every lane behind it.
|
||||||
|
#expect(CardStyleSection.target(forCard: card) != .board)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A card respelled in caps is the same target")
|
||||||
|
func theTargetIsKeyedByIdentityNotSpelling() {
|
||||||
|
// The window's own key rule (`CardWindowRef`), so the section cannot disagree with the window
|
||||||
|
// it sits in about which card it is aimed at.
|
||||||
|
let lower = ItemID(rawValue: Ident.card1)
|
||||||
|
let upper = ItemID(rawValue: Ident.card1.uppercased())
|
||||||
|
#expect(CardStyleSection.target(forCard: lower) == CardStyleSection.target(forCard: upper))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Style ▸ the anchor's geometry
|
||||||
|
|
||||||
|
@Suite("Card sidebar ▸ style editor layout")
|
||||||
|
struct StyleEditorLayoutTests {
|
||||||
|
|
||||||
|
/// How wide `columns` wells and the gaps between them actually draw.
|
||||||
|
private func gridWidth(columns: Int) -> CGFloat {
|
||||||
|
CGFloat(columns) * StyleEditorLayout.wellSide + CGFloat(columns - 1) * StyleEditorLayout.wellSpacing
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The popover anchor is unchanged by the sidebar's arrival")
|
||||||
|
func thePopoverKeepsItsSettledGeometry() {
|
||||||
|
// 268 points and 7 + 6 background wells are 03-board-ui.md's own numbers ("narrow enough to
|
||||||
|
// sit beside a card"), and the two anchors that were here first must not have moved because a
|
||||||
|
// third one needed different ones.
|
||||||
|
#expect(StyleEditorLayout.popover.width == 268)
|
||||||
|
#expect(StyleEditorLayout.popover.padding == 14)
|
||||||
|
#expect(StyleEditorLayout.popover.backgroundColumns == 7)
|
||||||
|
#expect(StyleEditorLayout.popover.symbolColumns == 8)
|
||||||
|
#expect(StyleEditorLayout.popover.symbolGridMaximumHeight == 168)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The sidebar anchor takes the column it is given and adds nothing to it")
|
||||||
|
func theSidebarBringsNoGeometryOfItsOwn() {
|
||||||
|
let layout = StyleEditorLayout.sidebar(contentWidth: 169)
|
||||||
|
|
||||||
|
// No width: the sidebar's is `CardWindowMetrics`' one decision. No padding: the section stack
|
||||||
|
// is already inset by a gutter, and insetting twice would narrow the grids for nothing.
|
||||||
|
#expect(layout.width == nil)
|
||||||
|
#expect(layout.padding == 0)
|
||||||
|
// No inner scroller: the sidebar is already a scroll view, and a scroll view inside a scroll
|
||||||
|
// view is a scroll view that fights (`CardWindowView`'s rule).
|
||||||
|
#expect(layout.symbolGridMaximumHeight == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The grids fit the sidebar at every text size, and waste no room doing it")
|
||||||
|
func theGridsFitTheSidebar() {
|
||||||
|
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
|
||||||
|
let available = CardWindowMetrics.sidebarContentWidth(bodyPointSize: size)
|
||||||
|
let layout = StyleEditorLayout.sidebar(contentWidth: available)
|
||||||
|
|
||||||
|
#expect(layout.backgroundColumns == layout.symbolColumns, "one column count for one column")
|
||||||
|
// Fits — a grid wider than its column puts the last well of every row out of reach of
|
||||||
|
// the pointer, at a text size nobody checks by eye.
|
||||||
|
#expect(gridWidth(columns: layout.backgroundColumns) <= available, "overflows at \(size)pt")
|
||||||
|
// And is maximal: one more well would not have fitted, so the wells are as large a set as
|
||||||
|
// the column can show rather than an arbitrary count that happened to be safe.
|
||||||
|
#expect(gridWidth(columns: layout.backgroundColumns + 1) > available, "under-packed at \(size)pt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The sidebar's grids are narrower than the popover's, at the standard text size")
|
||||||
|
func theSidebarIsTheNarrowerAnchor() {
|
||||||
|
// Which is the entire reason this type exists: the popover's 7 wells across do not fit a
|
||||||
|
// 26-character column, so an editor with one hard-coded frame could not have both anchors.
|
||||||
|
let layout = StyleEditorLayout.sidebar(contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13))
|
||||||
|
#expect(layout.backgroundColumns < StyleEditorLayout.popover.backgroundColumns)
|
||||||
|
#expect(layout.backgroundColumns >= 4, "a grid this narrow would be a strip, not a palette")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A column too narrow for even one well still asks for one")
|
||||||
|
func theFitRuleIsTotal() {
|
||||||
|
// Total over any width, because the caller is a layout system: a zero proposal during a
|
||||||
|
// window's first frame must not produce a grid of zero columns, which is a division by zero
|
||||||
|
// waiting in `LazyVGrid`.
|
||||||
|
#expect(StyleEditorLayout.columns(fitting: 0) == 1)
|
||||||
|
#expect(StyleEditorLayout.columns(fitting: -100) == 1)
|
||||||
|
#expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The sidebar's content width is the column minus its two gutters")
|
||||||
|
func theContentWidthIsTheColumnMinusItsGutters() {
|
||||||
|
for size in [11.0, 13.0, 24.0] as [CGFloat] {
|
||||||
|
#expect(
|
||||||
|
CardWindowMetrics.sidebarContentWidth(bodyPointSize: size)
|
||||||
|
== CardWindowMetrics.sidebarWidth(bodyPointSize: size)
|
||||||
|
- 2 * CardWindowMetrics.gutter(bodyPointSize: size)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 26 characters at half an em: 26 × 0.5 × 16 = 208, the text width the column was sized for.
|
||||||
|
#expect(CardWindowMetrics.sidebarContentWidth(bodyPointSize: 16) == 208)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,50 @@ struct TrashDeleteTests {
|
|||||||
#expect(store.selection.isEmpty)
|
#expect(store.selection.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("The card window's Delete is the same tombstone, and says nothing about the selection")
|
||||||
|
func cardWindowDeleteIsTheSameWrite() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
// Something *else* is selected on the board, which is the case the rule is about: the card
|
||||||
|
// window's card need not be the board's selection at all.
|
||||||
|
store.select([card3], liveness: .live, anchor: card3, head: card3)
|
||||||
|
|
||||||
|
store.deleteCard(card1)
|
||||||
|
|
||||||
|
// Byte-indistinguishable from the ⌫ tombstone above — same write op, same stamps, same
|
||||||
|
// minimal touch (05-card-window.md ▸ Actions: "Delete — tombstones the card").
|
||||||
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
#expect(try FrontmatterDocument.parse(after).deleted.value != nil)
|
||||||
|
#expect(!after.contains("modified-by"))
|
||||||
|
#expect(untouchedLines(after) == untouchedLines(before))
|
||||||
|
// ⌫ moves the selection to the successor sibling so a repeated keystroke walks down a lane.
|
||||||
|
// A button in another window has no such continuation, and re-pointing a selection that never
|
||||||
|
// lost anything would be the drag's mistake (`deleteByDrag`'s rule, shared).
|
||||||
|
#expect(store.selection.ids == [card3])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The card window's Delete writes nothing for a card that is already gone")
|
||||||
|
func cardWindowDeleteIsLiveOnly() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)")
|
||||||
|
let underTombstonedLane = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)")
|
||||||
|
|
||||||
|
// Its own tombstone, its lane's tombstone (effective liveness is ancestor-walked), and a card
|
||||||
|
// this board has never heard of. All three are windows already dismissing — nothing is ever
|
||||||
|
// written into a vanished folder.
|
||||||
|
store.deleteCard(card2)
|
||||||
|
store.deleteCard(card4)
|
||||||
|
store.deleteCard(ItemID(rawValue: "00000000-0000-4000-8000-000000000000"))
|
||||||
|
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified)
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == underTombstonedLane.modified)
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Already-tombstoned ids are skipped rather than re-stamped, and an empty set writes nothing")
|
@Test("Already-tombstoned ids are skipped rather than re-stamped, and an empty set writes nothing")
|
||||||
func liveOnly() throws {
|
func liveOnly() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
|
|||||||
Reference in New Issue
Block a user