Cards open to be read — the mobile detail screen turns read-only, editing moves behind a transactional Save

CardDetailScreen renders title, inline-Markdown body, and a quiet dates
footer; all writing moves to the new CardEditScreen, a full-screen cover
with segmented Details/Body panes. Drafts commit in one perform bracket
on Save only — Cancel guards dirty drafts with a discard confirmation,
and a failed write keeps the sheet and its drafts and raises an alert
instead of dismissing. CardAttributesSection becomes a pure
binding-driven editor with no write path of its own. The UI test walk
crosses the new split, with body-pane and discard coverage, and a
deterministic replaceAllText helper retires the flaky ⌘A select-all.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 14:47:50 -04:00
parent 28abaac2d9
commit c4591ead2c
6 changed files with 580 additions and 164 deletions
@@ -1,9 +1,14 @@
import SwiftUI
/// The card detail screen's Attributes section: icon, icon colour, and background colour the
/// three *typed* style fields a card carries (`FrontmatterFields.icon`/`.iconColor`/`.background`).
/// Every pick writes immediately through `BoardWriter.updateIndex` these are one-tap choices
/// from a fixed set, not free text, so there is no draft to debounce the way title/body have.
/// The card editor's Attributes section: icon, icon colour, and background colour the three
/// *typed* style fields a card carries (`FrontmatterFields.icon`/`.iconColor`/`.background`).
///
/// **Drafted, not written.** Every pick here mutates a `Binding<String?>` the caller owns
/// `CardEditScreen`'s own `@State` drafts rather than reaching for `BoardWriter` itself: the
/// transactional edit model commits title, body, and these three keys together in one `Save`, so
/// this section has no session, no card, and no write code of its own. It is a pure "edit these
/// three bindings" view; the icon picker sheet is unchanged mechanically, it just feeds a binding
/// instead of firing a write.
///
/// **There is no labels row.** `labels` is not a field either `BoardModel` or `FrontmatterDocument`
/// exposes as typed: `BoardModel.document`'s own doc comment names it as one of the reserved keys
@@ -13,10 +18,9 @@ import SwiftUI
/// surgical edits exist to keep (agent-written or hand-written overlays round-trip untouched). If
/// a typed `labels` field is ever added to the storage schema, its editor belongs in this section.
struct CardAttributesSection: View {
let card: Card
let laneID: ItemID
let cardID: ItemID
let session: BoardSession
@Binding var icon: String?
@Binding var iconColor: String?
@Binding var background: String?
@State private var isPresentingIconPicker = false
@@ -26,7 +30,7 @@ struct CardAttributesSection: View {
isPresentingIconPicker = true
} label: {
LabeledContent("Icon") {
if let icon = card.icon.value, !icon.isEmpty {
if let icon, !icon.isEmpty {
Image(systemName: icon)
} else {
Text("None").foregroundStyle(.secondary)
@@ -35,17 +39,17 @@ struct CardAttributesSection: View {
}
.tint(.primary)
swatchRow(title: "Icon Color", swatches: CardPalette.foregrounds, current: card.iconColor.value) { name in
setStyle(FrontmatterKeys.iconColor, to: name)
swatchRow(title: "Icon Color", swatches: CardPalette.foregrounds, current: iconColor) { name in
iconColor = name
}
swatchRow(title: "Background", swatches: CardPalette.backgrounds, current: card.background.value) { name in
setStyle(FrontmatterKeys.background, to: name)
swatchRow(title: "Background", swatches: CardPalette.backgrounds, current: background) { name in
background = name
}
}
.sheet(isPresented: $isPresentingIconPicker) {
IconPickerSheet(current: card.icon.value) { name in
setStyle(FrontmatterKeys.icon, to: name)
IconPickerSheet(current: icon) { name in
icon = name
}
}
}
@@ -77,28 +81,6 @@ struct CardAttributesSection: View {
}
.padding(.vertical, 4)
}
/// Writes one style key immediately. `operation: .style` is the vocabulary's own case for
/// "`updateIndex` on behalf of styling flows" (`WriteOperation.style`); `card.title.value` is
/// read before the closure runs so a failure banner can still name the card by the title on
/// screen.
private func setStyle(_ key: String, to value: String?) {
let laneID = self.laneID
let cardID = self.cardID
let cardTitle = card.title.value
Task {
// The closure's throws type must be spelled out a trailing closure literal does not
// pick up `perform`'s `throws(BoardWriteError)` from context alone.
await session.perform { (root: URL) throws(BoardWriteError) -> Void in
let folder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: cardTitle)) { document in
document.setStyleValue(value, for: key)
}
}
}
}
}
/// One colour well: a filled circle, a slashed placeholder for "None", and a selection ring.
+106 -93
View File
@@ -1,22 +1,17 @@
import SwiftUI
/// The card editor title, body, and attributes `BoardRoute.card`'s destination.
/// The card's read-only view `BoardRoute.card`'s destination.
///
/// Holds board-relative IDs only, never a `Card` value: every body evaluation re-reads the card
/// from `session.snapshot`, so a write this screen makes (or one that lands from elsewhere while
/// it's open) is reflected the moment the session's reload lands, and a card trashed on another
/// device is noticed rather than edited into thin air.
/// from `session.snapshot`, so a write this screen's own edit sheet makes (or one that lands from
/// elsewhere while it's open) is reflected the moment the session's reload lands, and a card
/// trashed on another device is noticed rather than shown stale.
///
/// ### Save timing
///
/// Title and body are edited into local `@State` drafts and committed through `BoardWriter` only
/// when they differ from the snapshot's own value never on every keystroke. Three triggers cover
/// every way editing can end without dropping a change: the field's own submit (title, on Return),
/// `onDisappear` (the user navigates back), and `scenePhase` leaving `.active` (the user backgrounds
/// the app, or is interrupted, mid-edit in the body editor). A `Done` toolbar button folds in the
/// same commit and drops focus, for a user who wants an explicit "I'm finished" without leaving the
/// screen. All four funnel through `commitAll()`, so there is exactly one place that decides what
/// "changed" means for each field.
/// **Read-only, by design.** There is no draft here and nothing this screen ever writes editing
/// lives entirely in `CardEditScreen`, presented full-screen from the Edit button. That split is
/// the whole point of the transactional model: a screen that only ever renders `session.snapshot`
/// cannot itself go stale relative to disk, and there is no auto-save timing to reason about
/// because there is no save at all on this side of the Edit button.
struct CardDetailScreen: View {
let boardRoot: URL
let laneID: ItemID
@@ -24,11 +19,8 @@ struct CardDetailScreen: View {
@Environment(BoardIndexStore.self) private var index
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@State private var titleDraft = ""
@State private var bodyDraft = ""
@FocusState private var isBodyFocused: Bool
@State private var isPresentingEdit = false
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
@@ -41,58 +33,35 @@ struct CardDetailScreen: View {
.navigationTitle("Card")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
isBodyFocused = false
commitAll()
if card != nil {
ToolbarItem(placement: .primaryAction) {
Button("Edit") { isPresentingEdit = true }
}
}
}
.task { session.open() }
// Seeds the drafts once per card. `.task(id:)` re-runs only when `card?.id` changes
// nil while the session is still opening, then the card's own id once it lands so a
// reload that lands *while this screen is open* (including the reload this screen's
// own commit triggers) never clobbers text the user is mid-typing.
.task(id: card?.id) {
guard let card else { return }
titleDraft = card.title.value ?? ""
bodyDraft = card.body
}
.onDisappear { commitAll() }
.onChange(of: scenePhase) { _, newPhase in
if newPhase != .active { commitAll() }
.fullScreenCover(isPresented: $isPresentingEdit) {
CardEditScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID)
}
}
@ViewBuilder
private var content: some View {
if let card {
Form {
if session.lastError != nil {
Section {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if session.lastError != nil {
Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Title") {
TextField("Untitled Card", text: $titleDraft)
.onSubmit { commitTitle(against: card) }
}
Section("Body") {
TextEditor(text: $bodyDraft)
.frame(minHeight: 200)
.focused($isBodyFocused)
}
CardAttributesSection(card: card, laneID: laneID, cardID: cardID, session: session)
Section("Details") {
LabeledContent("Created", value: card.created.value.map(Self.formatted) ?? "")
LabeledContent("Modified", value: card.modified.value.map(Self.formatted) ?? "")
titleBlock(for: card)
bodyBlock(for: card)
detailsFooter(for: card)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
} else if case .ready = session.phase {
ContentUnavailableView("Card Removed", systemImage: "trash", description: Text("This card was deleted."))
@@ -102,56 +71,100 @@ struct CardDetailScreen: View {
}
}
private func commitAll() {
guard let card else { return }
commitTitle(against: card)
commitBody(against: card)
/// The title, its icon when the card has one, and a subtle background tint lifted from the
/// card's own `background` swatch passive display only, no swatches to tap here. An
/// unrecognized colour name (the same leniency `ItemIconView`/`CardPalette` document for
/// themselves) just skips the tint rather than guessing at one.
@ViewBuilder
private func titleBlock(for card: Card) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
ItemIconView(icon: card.icon.value, iconColor: card.iconColor.value)
titleText(for: card)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(
(tintColor(for: card)?.opacity(0.15) ?? Color.clear),
in: RoundedRectangle(cornerRadius: 12)
)
}
private func commitTitle(against card: Card) {
let trimmed = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed != (card.title.value ?? "") else { return }
let laneID = self.laneID
let cardID = self.cardID
let newValue: String? = trimmed.isEmpty ? nil : trimmed
Task {
// The closure's throws type must be spelled out a trailing closure literal does not
// pick up `perform`'s `throws(BoardWriteError)` from context alone.
await session.perform { (root: URL) throws(BoardWriteError) -> Void in
let folder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
// The Mac's own rename path, verbatim (`BoardStore.setTitle`): plain `set`/`remove`
// `setStyleValue` is the style gesture's helper, not the title's and
// `.rename(title: nil)` so `updateIndex` enriches the operation from the document
// it reads rather than trusting a snapshot that may have aged.
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newValue {
document.set(FrontmatterKeys.title, to: .string(newValue))
} else {
document.remove(FrontmatterKeys.title)
}
@ViewBuilder
private func titleText(for card: Card) -> some View {
if let title = card.title.value, !title.isEmpty {
Text(title)
.font(.title.bold())
} else {
Text("Untitled Card")
.font(.title.bold())
.foregroundStyle(.secondary)
}
}
private func tintColor(for card: Card) -> Color? {
card.background.value.flatMap { CardPalette.color(named: $0, in: CardPalette.backgrounds) }
}
/// The body, rendered as Markdown see `MarkdownBlocks` for why this is a per-paragraph
/// `AttributedString(markdown:)` pass rather than a full block parser.
@ViewBuilder
private func bodyBlock(for card: Card) -> some View {
let paragraphs = MarkdownBlocks.paragraphs(in: card.body)
if paragraphs.isEmpty {
Text("No description")
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, paragraph in
Text(MarkdownBlocks.rendered(paragraph))
}
}
}
}
private func commitBody(against card: Card) {
guard bodyDraft != card.body else { return }
let laneID = self.laneID
let cardID = self.cardID
let newBody = bodyDraft
Task {
await session.perform { (root: URL) throws(BoardWriteError) -> Bool in
let folder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
return try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
}
@ViewBuilder
private func detailsFooter(for card: Card) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("Created \(card.created.value.map(Self.formatted) ?? "")")
Text("Modified \(card.modified.value.map(Self.formatted) ?? "")")
}
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 8)
}
private static func formatted(_ date: Date) -> String {
date.formatted(date: .abbreviated, time: .shortened)
}
}
/// Dependency-free Markdown rendering for a card body: no `swift-markdown` block parser here, just
/// `Foundation`'s own `AttributedString(markdown:options:)` run once per paragraph.
///
/// **Why per-paragraph.** `AttributedString`'s built-in parser has no "render this as a sequence of
/// blocks" mode short of `.full`, which produces a block tree this view would still have to walk
/// no simpler than doing the walk ourselves, and it collapses the very newlines a card body relies
/// on to separate paragraphs. Splitting on blank lines first and parsing each paragraph with
/// `.inlineOnlyPreservingWhitespace` gets both halves of what a card body needs cheaply: inline
/// emphasis/links/code render as styled runs, and the newlines *inside* a paragraph (soft line
/// breaks) survive as literal whitespace instead of being folded into a single space.
enum MarkdownBlocks {
/// Splits `body` on runs of one or more blank lines Markdown's own paragraph boundary and
/// trims each block, so a body with trailing blank lines or extra spacing between paragraphs
/// doesn't render phantom empty blocks.
static func paragraphs(in body: String) -> [String] {
body
.components(separatedBy: "\n\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
/// One paragraph, inline-parsed. Malformed Markdown (an unterminated code span, say) falls back
/// to the raw paragraph text rather than dropping it the same "never lose the author's text"
/// posture `CardPalette.color(named:in:)` takes for an unrecognized colour name.
static func rendered(_ paragraph: String) -> AttributedString {
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
return (try? AttributedString(markdown: paragraph, options: options)) ?? AttributedString(paragraph)
}
}
+276
View File
@@ -0,0 +1,276 @@
import SwiftUI
/// The card editor title, body, and attributes, all drafted and committed together presented
/// full-screen from `CardDetailScreen`'s Edit button.
///
/// Holds board-relative IDs only, never a `Card` value, for the same reason every other screen in
/// this stack does: the card is re-read from `session.snapshot` on every body evaluation, so a
/// write landing from elsewhere while this sheet is open (a trash from another device, say) is
/// noticed rather than edited into thin air.
///
/// ### Transactional, not auto-saving
///
/// Every earlier screen in this app commits a field the moment it stops being edited `onSubmit`,
/// `onDisappear`, a scene-phase change. This one commits nothing until `Save`, deliberately: a
/// `fullScreenCover` has no interactive swipe-to-dismiss, so the only way out mid-edit is a button
/// this screen controls, which makes "ask before discarding" both possible and the more honest
/// default for a screen with five drafted fields open across two panes at once. There is no
/// scenePhase or onDisappear commit anywhere below backgrounding the app or losing focus leaves
/// the drafts exactly where they were, waiting for `Save` or `Cancel`.
///
/// `Cancel` on a clean sheet dismisses immediately; on a dirty one it raises a confirmation before
/// discarding. `Save` writes every changed field in one `session.perform` bracket title through
/// `.rename`, the three style keys through one `.style` call, body through `writeBody` writes
/// nothing at all when nothing changed, and dismisses only on success: a failed write keeps the
/// sheet and its drafts and raises an alert (see `save()`).
struct CardEditScreen: View {
let boardRoot: URL
let laneID: ItemID
let cardID: ItemID
@Environment(BoardIndexStore.self) private var index
@Environment(\.dismiss) private var dismiss
private enum Pane: String, CaseIterable, Hashable {
case details = "Details"
case body = "Body"
}
@State private var pane: Pane = .details
@State private var titleDraft = ""
@State private var bodyDraft = ""
@State private var iconDraft: String?
@State private var iconColorDraft: String?
@State private var backgroundDraft: String?
@State private var isConfirmingDiscard = false
@State private var saveError: BoardSessionError?
@FocusState private var isBodyFocused: Bool
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
private var card: Card? {
session.snapshot?.lanes.first { $0.id == laneID }?.cards.first { $0.id == cardID }
}
var body: some View {
NavigationStack {
content
.navigationTitle("Card")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { requestDismiss() }
}
if card != nil {
ToolbarItem(placement: .principal) {
Picker("Pane", selection: $pane) {
ForEach(Pane.allCases, id: \.self) { pane in
Text(pane.rawValue).tag(pane)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 220)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
}
}
}
.task { session.open() }
// Seeds the drafts once per card. `.task(id:)` re-runs only when `card?.id`
// changes nil while the session is still opening, then the card's own id once
// it lands so a reload that lands *while this sheet is open* never clobbers a
// draft the user is mid-typing. Unlike the old in-place editor, nothing this
// screen does triggers such a reload before `Save` but another device's write
// can still land, and the same guard covers that case too.
.task(id: card?.id) {
guard let card else { return }
titleDraft = card.title.value ?? ""
bodyDraft = card.body
iconDraft = card.icon.value
iconColorDraft = card.iconColor.value
backgroundDraft = card.background.value
}
.confirmationDialog(
"Discard Changes?",
isPresented: $isConfirmingDiscard,
titleVisibility: .visible
) {
Button("Discard", role: .destructive) { dismiss() }
Button("Cancel", role: .cancel) {}
}
.alert(
"Couldn't Save",
isPresented: Binding(get: { saveError != nil }, set: { if !$0 { saveError = nil } }),
presenting: saveError
) { _ in
Button("OK") { saveError = nil }
} message: { error in
Text(error.description)
}
}
}
@ViewBuilder
private var content: some View {
if let card {
switch pane {
case .details:
detailsForm(for: card)
case .body:
TextEditor(text: $bodyDraft)
.focused($isBodyFocused)
.padding(.horizontal, 12)
.padding(.top, 8)
}
} else if case .ready = session.phase {
// The card is gone from a `.ready` snapshot deleted elsewhere while this sheet was
// open. There is nothing left to edit; dismiss the cover rather than leave drafts for
// a card that no longer exists.
ContentUnavailableView("Card Removed", systemImage: "trash", description: Text("This card was deleted."))
.task { dismiss() }
} else {
ProgressView("Opening card")
}
}
@ViewBuilder
private func detailsForm(for card: Card) -> some View {
Form {
if session.lastError != nil {
Section {
Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Title") {
TextField("Untitled Card", text: $titleDraft)
}
CardAttributesSection(icon: $iconDraft, iconColor: $iconColorDraft, background: $backgroundDraft)
Section("Details") {
LabeledContent("Created", value: card.created.value.map(Self.formatted) ?? "")
LabeledContent("Modified", value: card.modified.value.map(Self.formatted) ?? "")
}
}
}
/// Any draft differing from the card's own current snapshot value title compared trimmed,
/// exactly as `Save` compares it, so this predicate and the write it gates never disagree.
private var isDirty: Bool {
guard let card else { return false }
let trimmedTitle = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedTitle != (card.title.value ?? "")
|| bodyDraft != card.body
|| iconDraft != card.icon.value
|| iconColorDraft != card.iconColor.value
|| backgroundDraft != card.background.value
}
private func requestDismiss() {
if isDirty {
isConfirmingDiscard = true
} else {
dismiss()
}
}
/// One `session.perform` bracket, three independent writes inside it gated on whatever
/// actually changed a clean `Save` (nothing dirty) makes no write at all and just dismisses.
/// Awaits the perform before dismissing: `perform` already awaits its own reload, so by the
/// time this returns `CardDetailScreen`'s snapshot underneath already reflects the write, and
/// the read screen shows the new values the instant the cover animates away.
///
/// Dismissal is gated on the perform's own `Result` the one caller of `perform` in this app
/// that can't ignore it. The auto-committing screens could fire and forget because their
/// drafts outlived the write attempt; here dismissing *is* draft destruction, so a failed
/// write keeps the sheet (and every draft) exactly where it was and raises an alert instead.
/// `perform` reloads even on failure, so a partially-applied multi-call write (title landed,
/// body didn't) re-seeds nothing the drafts keep the user's full intent and a retry rewrites
/// only what still differs.
private func save() {
guard let card else {
dismiss()
return
}
let trimmedTitle = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines)
let titleChanged = trimmedTitle != (card.title.value ?? "")
let newTitle: String? = trimmedTitle.isEmpty ? nil : trimmedTitle
let iconChanged = iconDraft != card.icon.value
let iconColorChanged = iconColorDraft != card.iconColor.value
let backgroundChanged = backgroundDraft != card.background.value
let styleChanged = iconChanged || iconColorChanged || backgroundChanged
let bodyChanged = bodyDraft != card.body
guard titleChanged || styleChanged || bodyChanged else {
dismiss()
return
}
let laneID = self.laneID
let cardID = self.cardID
let cardTitle = card.title.value
let newIcon = iconDraft
let newIconColor = iconColorDraft
let newBackground = backgroundDraft
let newBody = bodyDraft
Task {
// The closure's throws type must be spelled out a trailing closure literal does not
// pick up `perform`'s `throws(BoardWriteError)` from context alone.
let outcome = await session.perform { (root: URL) throws(BoardWriteError) -> Void in
let folder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
if titleChanged {
// The Mac's own rename path, verbatim (`BoardStore.setTitle`): plain
// `set`/`remove` `setStyleValue` is the style gesture's helper, not the
// title's and `.rename(title: nil)` so `updateIndex` enriches the operation
// from the document it reads rather than trusting a snapshot that may have
// aged.
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newTitle {
document.set(FrontmatterKeys.title, to: .string(newTitle))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
if styleChanged {
// One `.style` call for all three keys `operation: .style` is the
// vocabulary's own case for "`updateIndex` on behalf of styling flows"
// (`WriteOperation.style`), and each changed key writes through
// `setStyleValue` exactly as the old immediate-write attributes section did.
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: cardTitle)) { document in
if iconChanged { document.setStyleValue(newIcon, for: FrontmatterKeys.icon) }
if iconColorChanged { document.setStyleValue(newIconColor, for: FrontmatterKeys.iconColor) }
if backgroundChanged { document.setStyleValue(newBackground, for: FrontmatterKeys.background) }
}
}
if bodyChanged {
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
}
}
switch outcome {
case .success:
dismiss()
case let .failure(error):
saveError = error
}
}
}
private static func formatted(_ date: Date) -> String {
date.formatted(date: .abbreviated, time: .shortened)
}
}