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.