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
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user