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() } } } } } }