Files
lanework/KanbanMobile/Screens/CardAttributesSection.swift
T
rzen eac1c02a7d The phone joins the format — KanbanMobile MVP: shared storage verbatim over an iCloud container
A second product, not a second edition: dev.rzen.indie.KanbanMobile (iOS 26,
iPhone-only) compiles Kanban/Storage as source files, so a format change that
breaks the phone breaks this build the day it's made. Boards live in
iCloud.dev.rzen.indie.Kanban — named after the Mac bundle id so the Mac app can
adopt the container later without a migration; until then the folder is
"Lanework" in iCloud Drive and the Mac opens boards there through the open
panel.

EchoLedger grows #if os(macOS) gates around its three consumer surfaces
(verdicts/BoardDiff, harvest/HarvestedReceipt, comment retirement/CommentPath)
— the recording side BoardWriter stamps compiles on every platform, and the
gates are the seam a future phone verdict surface lands behind. AgentGuide
stays Mac-only.

The phone's watcher is NSMetadataQuery: BoardIndexStore (one query, package
UTI export makes a .kanban directory one item, equality-gated rescans,
download kicks per pass), BoardSession (materialization sweep before every
fail-fast walk, NSFileCoordinator brackets, perform{} = coordinated write then
awaited reload, ParseMemo threaded), CloudHome (off-main container resolution,
LANEWORK_LOCAL_ROOT DEBUG override for simulator work without an account).

Screens: Boards -> lanes -> cards -> card editor, value-routed by ItemID with
every screen re-reading the live snapshot; leading swipe moves a card via
confirmationDialog, trailing swipe sends it to .trash/; the editor commits
title through the Mac's canonical rename path and body through writeBody, with
drafts that survive reloads; attributes are the three typed style fields
(icon, iconColor, background) — labels is a reserved unknown-field key and
deliberately has no editor. Settings carries IndieBackup (backup root = the
container's Documents, restore rebuild = an index rescan, controller
constructed only once the home resolves).

Arbiter: KanbanMobile green for iOS Simulator, Kanban green for macOS, 3006
unit tests / 517 suites passed (PointerLatencyTests excluded — mid-rework
uncommitted in a parallel session).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 22:49:36 -04:00

176 lines
6.9 KiB
Swift

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.
///
/// **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
/// that "ride along uninterpreted via `document.unknownFields`", alongside `assignees`, `due`,
/// `remote`. Rendering or editing it here would mean this screen parsing and rewriting a key the
/// model layer deliberately treats as opaque — exactly the unknown-field promise `updateIndex`'s
/// 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
@State private var isPresentingIconPicker = false
var body: some View {
Section("Attributes") {
Button {
isPresentingIconPicker = true
} label: {
LabeledContent("Icon") {
if let icon = card.icon.value, !icon.isEmpty {
Image(systemName: icon)
} else {
Text("None").foregroundStyle(.secondary)
}
}
}
.tint(.primary)
swatchRow(title: "Icon Color", swatches: CardPalette.foregrounds, current: card.iconColor.value) { name in
setStyle(FrontmatterKeys.iconColor, to: name)
}
swatchRow(title: "Background", swatches: CardPalette.backgrounds, current: card.background.value) { name in
setStyle(FrontmatterKeys.background, to: name)
}
}
.sheet(isPresented: $isPresentingIconPicker) {
IconPickerSheet(current: card.icon.value) { name in
setStyle(FrontmatterKeys.icon, to: name)
}
}
}
@ViewBuilder
private func swatchRow(
title: String,
swatches: [CardPalette.Swatch],
current: String?,
onSelect: @escaping (String?) -> Void
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.subheadline)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
SwatchButton(isSelected: current == nil, color: nil) { onSelect(nil) }
ForEach(swatches) { swatch in
SwatchButton(
isSelected: current == swatch.name,
color: CardPalette.color(named: swatch.name, in: swatches)
) {
onSelect(swatch.name)
}
}
}
}
}
.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.
private struct SwatchButton: View {
let isSelected: Bool
let color: Color?
let action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.fill(color ?? Color(.systemGray5))
.frame(width: 28, height: 28)
.overlay {
if color == nil {
Image(systemName: "slash.circle")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.overlay {
Circle()
.strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)
.padding(-3)
}
}
.buttonStyle(.plain)
}
}
/// The icon picker's grid — `CardPalette.icons` plus a "None" well that removes the key.
private struct IconPickerSheet: View {
let current: String?
let onSelect: (String?) -> Void
@Environment(\.dismiss) private var dismiss
private let columns = Array(repeating: GridItem(.flexible()), count: 6)
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
Button {
onSelect(nil)
dismiss()
} label: {
Image(systemName: "slash.circle")
.font(.title2)
.foregroundStyle(current == nil ? Color.accentColor : .secondary)
}
ForEach(CardPalette.icons, id: \.self) { name in
Button {
onSelect(name)
dismiss()
} label: {
Image(systemName: name)
.font(.title2)
.foregroundStyle(current == name ? Color.accentColor : .primary)
}
}
}
.padding()
}
.navigationTitle("Icon")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
}