Files
lanework/Kanban/UI/Card/CardCommentsCommands.swift
rzen fe3ffac48e Comments, phase 2 — the pane, the composer, and the inline session
The card window recomposes into three componentized panes (body,
comments, attributes) with two mounts — beside or body-over-comments
at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is
one persisted app-wide bit, no content-derived auto-show; File ▸ Add
Comment flips it on and focuses the composer. The thread renders
author lines, edited markers, card-subset Markdown bodies, and
read-only Quick Look chips under a count header with the sort-
direction control. The composer edits comments/.draft/ on the slow
cadence (blur, close, quit, ~30s interval), Escape only moves focus,
⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms
debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start
bytes, close flushes. File drops within either authoring surface
carve out of the window-wide card default into that surface's
attachments/; paperclips cover the no-drag path. Close flush runs
inline flush, then draft save, then the comments/.trash purge;
open sweeps crash residue.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 20:19:52 -04:00

128 lines
6.3 KiB
Swift

import SwiftUI
// MARK: - View ▸ Show Comments
/// View ▸ Show Comments (checkmark toggle, **no default chord**) — 11-command-nexus.md;
/// 05-card-window.md ▸ The comments column.
///
/// ### One bit, app-wide, persisted — and the checkmark *is* the bit
///
/// > **View ▸ Show Comments** is a checkmark toggle à la Show Trash, and its choice is **app-wide and
/// > persisted across restarts**. One bit, no content-derived auto-show: checked, every card window
/// > carries the pane (a comment-less card shows the empty thread and the composer — the invitation
/// > is the point); unchecked, threads and drafts are out of sight until the user says otherwise, the
/// > Show Trash bargain. The checkmark reads the bit — the menu never lies.
///
/// `@AppStorage` is that sentence with no machinery under it: the row's `isOn` reads the very default
/// every card window's pane reads, so there is no per-window mirror to keep in step and no auto-show
/// rule that could disagree with the tick. "Deleting the last comment never closes the pane" needs no
/// code at all for the same reason — nothing but this row writes the key.
///
/// **No App Group.** The suite is `UserDefaults.standard`, which the sandbox already scopes to this
/// one app (`AppPreferences`); the group suite the 2026-07-29 ruling named went with the App Group
/// itself when the edition split collapsed.
///
/// Validation is **scope and nothing else**: with no card window in front there is no `cardComments`
/// focused value and the row disables. The read-only lock is deliberately not part of it — showing a
/// pane is not a mutation, exactly as entering Edit is not (`EditBodyCommand`).
struct ShowCommentsCommand: View {
@FocusedValue(\.cardComments) private var comments
@AppStorage(AppPreferences.showCommentsKey) private var isShown = true
/// The row's validation, as a value a test can hold — `EditBodyCommand.isEnabled`'s shape, for
/// its reason: a menu item's `.disabled` is otherwise only observable by driving the menu bar.
static func isEnabled(_ comments: CardComments?) -> Bool {
comments != nil
}
var body: some View {
Toggle("Show Comments", isOn: $isShown)
.disabled(!Self.isEnabled(comments))
}
}
// MARK: - View ▸ Comments Beside Body
/// View ▸ Comments Beside Body (checkmark toggle, **no default chord**) — "checked = side-by-side
/// (default), unchecked = body over comments; app-wide, persisted" (11-command-nexus.md;
/// 05-card-window.md ▸ Composition).
///
/// `ShowCommentsCommand`'s shape exactly, and for its reasons — one persisted bit, the checkmark
/// reading it, scope-only validation. The two rows sit together because they are the pane's two
/// user-facing facts and 11 lists them adjacent.
///
/// It stays enabled while Show Comments is off. The row is a *layout* preference, not a second
/// visibility switch, and a user arranging their window before turning the pane on is doing something
/// perfectly ordinary — the alternative (disabling it) would also make the checkmark lie about a bit
/// that is still stored and still applies the moment the pane appears.
struct CommentsBesideBodyCommand: View {
@FocusedValue(\.cardComments) private var comments
@AppStorage(AppPreferences.commentsBesideBodyKey) private var isBeside = true
static func isEnabled(_ comments: CardComments?) -> Bool {
comments != nil
}
var body: some View {
Toggle("Comments Beside Body", isOn: $isBeside)
.disabled(!Self.isEnabled(comments))
}
}
// MARK: - File ▸ Add Comment
/// File ▸ Add Comment (**no default chord**) — card window, all tiers (11-command-nexus.md;
/// 05-card-window.md ▸ The comments column).
///
/// ### One gesture, two effects, in the one order that works
///
/// > **File ▸ Add Comment** flips the bit on when it's off (the gesture *is* the user choosing to see
/// > comments — same persistence) and focuses the composer in one gesture.
///
/// The write comes first and the focus request second, because the composer does not exist to be
/// focused until the pane is mounted. The request is a counter on the window's handle rather than a
/// call into a text view (`CardComments.focusComposerRequests`), so the pane picks it up on the
/// update after the one that mounted it — which is the only ordering that survives the pane arriving
/// in the same frame.
///
/// **Flipping the bit is persisted like any other flip of it.** That is the ruling, stated in the
/// design as "the same user choice": a user who reaches for Add Comment has said they want to see
/// comments, and a visibility that reverted at the next window would make the row a one-shot.
///
/// Validation is scope. The read-only lock is not part of it, `EditBodyCommand`'s rule: this focuses
/// a text surface, and 02-architecture.md keeps editor buffers alive under the lock (only their saves
/// suspend), so a locked board can still be typed into and its text copied out. What the lock does
/// disable is the composer's Post button and its paperclip, in place, where they are.
struct AddCommentCommand: View {
@FocusedValue(\.cardComments) private var comments
@AppStorage(AppPreferences.showCommentsKey) private var isShown = true
static func isEnabled(_ comments: CardComments?) -> Bool {
comments != nil
}
/// What the row does to the two pieces of state it touches, as a pure pair a test can drive: the
/// bit's new value, and whether a focus request is owed.
///
/// It is extracted for the reason every menu-row rule in this codebase is: "turns it on when it
/// is off, and focuses either way" is one sentence with two clauses, and the clause that
/// regresses silently is the second one — a row that only focused when it had just turned the
/// pane on would look correct in the demo and be wrong every time after.
static func effect(isShown: Bool) -> (isShown: Bool, focusesComposer: Bool) {
(isShown: true, focusesComposer: true)
}
var body: some View {
Button("Add Comment") {
let effect = Self.effect(isShown: isShown)
isShown = effect.isShown
guard effect.focusesComposer else { return }
comments?.focusComposer()
}
.disabled(!Self.isEnabled(comments))
}
}