import AppKit import SwiftUI // MARK: - Style /// The sidebar's **Style** section: the **Background color combo** over the compact **`SymbolPicker`** /// (05-card-window.md ▸ Style; 03-board-ui.md ▸ Styling ▸ Controls, its 2026-08-06 anchor-ownership /// rule for the background half, extended to the symbol half on 2026-08-08). Card styling is /// discoverable here without a context menu — the picker is the same reusable control the board /// popover's glyph picker beside the rename field already uses (`BoardInfoPopover`). /// /// ### Why the curated grid left /// /// `StyleEditorView`'s curated grid used to draw **whole, uncollapsed** in this column /// (`StyleEditorLayout.sidebar`'s `symbolGridMaximumHeight: nil` — "the sidebar is already a scroll /// view, and a scroll view inside a scroll view is a scroll view that fights") — a five-to-seven-row /// grid, permanently open, ahead of every other section the sidebar carries. `SymbolPicker` is the /// general-purpose control built for precisely this shape of problem (its own header: "a control any /// caller can aim at one symbol field without wiring up a `BoardStore`, a `StyleTarget`, or the /// two-dimension batch machinery `StyleEditorView` carries") — one small well at rest, the grid only /// while its popover is open. This section is the first caller to aim it at a store-backed field /// rather than a plain binding, which is why it does the store plumbing below that `SymbolPicker` /// itself deliberately knows nothing about. /// /// **`StyleEditorView` is no longer instantiated here at all.** It was already `showsBackground: /// false` — the color combo has owned the background half since 2026-08-06 — so the curated symbol /// grid was its only remaining job in this section, and that job is what moved. The shared /// component's `.sidebar` layout variant and `showsBackground` toggle stay in `StyleEditor.swift` /// regardless: they are still correct and still tested (`StyleEditorLayoutTests`), and that file's own /// stance is to be anchor-agnostic about callers it does not currently have. Retiring them as dead /// code is a separate, larger cut this card does not make. /// /// ### The curated set does not shrink /// /// `SymbolPicker`'s own default (`SymbolPickerCatalog.defaultSet`, 36 general "boards and projects" /// glyphs) is deliberately smaller than `CuratedSymbols.cards` (~40, work-item/content-flavored — one /// of the three level-specific sets `CuratedSymbols` split into, 2026-08-09) — `SymbolPicker.swift`'s /// own doc comment states why the lists are free to diverge. A card is exactly the level /// `CuratedSymbols.cards` is grouped for, so this section passes it explicitly (`symbols: /// CuratedSymbols.availableCards`) rather than taking the picker's domain-agnostic default: the /// Style… popover's card-level grid and this section now offer the identical curated vocabulary, /// through two different controls. /// /// ### Writes still ride the one funnel, on this window's own stack /// /// `onSelect`/`onSelectColor` below call `StyleCommand.apply(icon:iconColor:to:in:store:recents:on:)` /// — the same funnel `StyleEditorView`'s wells rode, `on: undo` passed through exactly as before /// (13-native-undo.md ▸ Rules ▸ two levels: a colour or symbol chosen here is a gesture *issued in /// this window*, so its step joins the window's session and reaches board history only inside the /// coarse close step — this section's own note on `undo` below, unchanged by the control swap). /// Nothing about `BoardStore.applyStyle`'s batch semantics, its no-op skipping, or its `HistoryAnchor` /// choice moved; only the view that calls it did — `CardSessionUndoTests.sidebarStylingIsASessionGesture` /// calls that same funnel directly and needed no change. /// /// ### The target is fixed, and that is the whole difference in behavior /// /// The Style… popover *tracks*: its target is the selection at the moment the gesture named it, /// re-resolved against every snapshot, dismissing when it empties (`StyleEditorSession`). This /// section tracks nothing. Its target is this window's card, always — "the two embedded anchors need /// none of this and get none: the card sidebar dismisses with its card's window, and the board /// popover's target is the board itself" (`StyleEditorSession`'s own note). `SymbolPicker`'s own /// popover presentation is plain view-local `@State`, needing no session for the same reason: the /// window's card cannot vanish out from under its own window, and when it does the window goes with /// it (`CardWindowFate`). struct CardStyleSection: View { let store: BoardStore let recents: StyleRecents let cardID: ItemID /// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels): a colour or symbol /// chosen here is a gesture *issued in this window*, so its step joins the window's session and /// reaches board history only inside the coarse close step. The background combo below and the /// symbol picker both take it as a parameter, for the same reason. let undo: CardWindowUndo /// The trailing debounce on a live colour-panel drag (`ColorComboView`'s `onPanelChange`, /// opened from the combo's **Other…** row): cancelled and replaced on every tick, so only the /// value the user is still on ~400ms after the last one actually reaches disk. One task for the /// section's one combo. @State private var backgroundPanelCommit: Task? /// The live body metric, read here rather than passed in — `CardAttachmentsSection`'s pattern, /// so every section in this sidebar derives its geometry the same way. private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } /// **What this section styles: this card, and nothing else.** /// /// A one-line seam because it is a claim worth pinning rather than a computation worth reading: /// every other anchor derives its target from something that moves (the selection, the board), /// and the mistake this window could make is inheriting one of those. `.items` of exactly one id /// also means `applyStyle`'s own batch machinery is a no-op here — one subject, so the display is /// never mixed and the write bracket holds one file. nonisolated static func target(forCard id: ItemID) -> StyleTarget { .items([id]) } var body: some View { VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) { CardSidebarSectionHeader(title: "Style") backgroundComboRow symbolRow } .frame(maxWidth: .infinity, alignment: .leading) } // MARK: - Background combo /// The labeled **Background** row, above the well grid — a narrower, single-value alternative /// to it (`ColorCombo.swift`'s own doc comment): an inspector row, caption leading and a /// compact combo trailing, the arrangement every Xcode inspector uses for exactly this control. /// The combo takes just over half the row rather than filling it — sized off the same metric /// the sidebar's own width comes from, so the pair holds its proportions at every text size. private var backgroundComboRow: some View { HStack(spacing: 0) { Text("Background") .font(.caption) .foregroundStyle(.secondary) Spacer(minLength: 8) ColorComboView( role: .background, value: currentBackground, isEnabled: !store.isReadOnly, onChange: { commitBackground($0) }, onPanelChange: { debounceBackground($0) } ) .frame(width: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize) * 0.55) } .frame(maxWidth: .infinity, alignment: .leading) } /// The card's `background` field, exactly as written — malformed reads as its raw text, missing /// reads `nil`, both `StyleFieldState.written`'s own rule (`StyleModel.swift`). The **raw** /// string, never a resolved colour: `ColorComboModel`'s matching needs the bytes, not what they /// render as. private var currentBackground: String? { StyleFieldState.written(currentSubject?.background ?? .missing) } /// A discrete pick — commits immediately. `nil` removes; a name from `Palette.backgrounds` goes /// through `StyleCommand.apply` so it feeds `StyleRecents` exactly like a well click would /// ("updated on every background application from any anchor", `StyleEditor.swift`); anything /// else — the dynamic current-value row re-affirming a foreign name or a custom hex — writes /// directly, since it is not the "palette pick" recents was ever meant to remember. private func commitBackground(_ newValue: String?) { let target = Self.target(forCard: cardID) guard let newValue else { store.applyStyle(to: target, background: .remove, icon: .keep, on: undo) return } if Palette.backgrounds.contains(where: { $0.name == newValue }) { StyleCommand.apply(background: .set(newValue), to: target, in: store, recents: recents, on: undo) } else { store.applyStyle(to: target, background: .set(newValue), icon: .keep, on: undo) } } /// One tick of a live colour-panel drag: cancels whatever commit was pending and schedules a new /// one ~400ms out, so a drag writes once it settles rather than on every pixel it passes through. /// Never routed through `StyleCommand.apply` — a drag that passes through a palette-exact hex /// mid-gesture must not spam the recents row the way a deliberate pick would. private func debounceBackground(_ newValue: String?) { backgroundPanelCommit?.cancel() let target = Self.target(forCard: cardID) backgroundPanelCommit = Task { @MainActor in try? await Task.sleep(for: .milliseconds(400)) guard !Task.isCancelled else { return } if let newValue { store.applyStyle(to: target, background: .set(newValue), icon: .keep, on: undo) } else { store.applyStyle(to: target, background: .remove, icon: .keep, on: undo) } } } // MARK: - Symbol /// The labeled **Symbol** row, below the background combo — `backgroundComboRow`'s own /// inspector-row shape, restated: caption leading, the compact control trailing. Unlike the /// combo, the picker gets no width of its own — `SymbolPicker`'s at-rest well is already /// font-derived and small (`SymbolPickerLayout.restSide`), matched to a text field's height, and /// stretching it would just be empty frame around a fixed-size button. private var symbolRow: some View { HStack(spacing: 0) { Text("Symbol") .font(.caption) .foregroundStyle(.secondary) Spacer(minLength: 8) SymbolPicker( current: currentSymbol, fallback: ItemSymbol.card, symbols: CuratedSymbols.availableCards, onSelect: { applySymbol($0) }, currentColor: currentIconColor, onSelectColor: { applyIconColor($0) } ) // The same lock `StyleEditorView`'s whole body disabled under // (`.disabled(!store.acceptsBoardMutations)`, `StyleEditor.swift`) — the read-only lock // and the board's inline-editing rule alike, preserved exactly across the control swap // rather than narrowed to `backgroundComboRow`'s plainer `!store.isReadOnly`. .disabled(!store.acceptsBoardMutations) } .frame(maxWidth: .infinity, alignment: .leading) } /// One `styleSubjects` lookup behind `currentBackground` above and the two properties below — /// a card window names exactly one target, so there is exactly one subject to read for all three /// fields. private var currentSubject: BoardStore.StyleSubject? { store.styleSubjects(of: Self.target(forCard: cardID)).first } /// The card's `icon` field, exactly as written — `currentBackground`'s rule, restated for the /// field `SymbolPicker.current` reads. Resolution (an unresolvable name falling back to the level /// default) is `SymbolPicker`'s own job, not this property's — `ItemSymbol.name(_:fallback:)`'s /// lenient rule, which the picker's `resolvedName` already applies. private var currentSymbol: String? { StyleFieldState.written(currentSubject?.icon ?? .missing) } /// The card's `iconColor` field, exactly as written — one dimension over from `currentSymbol`, /// feeding `SymbolPicker.currentColor`. private var currentIconColor: String? { StyleFieldState.written(currentSubject?.iconColor ?? .missing) } /// `nil` clears back to the card default (`icon` removed); a name sets it — `StyleChange`'s /// `set`/`remove` split, `SymbolPicker.onSelect`'s own contract turned into the vocabulary /// `StyleCommand.apply` speaks. Routed through the funnel rather than `store.applyStyle` directly /// so a symbol pick joins `StyleRecents` exactly as a background pick does — moot today (only /// `.set(background:)` records, `StyleCommand.apply`'s own rule), but future-proof against that /// rule ever widening to symbols. private func applySymbol(_ name: String?) { StyleCommand.apply( icon: name.map { StyleChange.set($0) } ?? .remove, to: Self.target(forCard: cardID), in: store, recents: recents, on: undo ) } /// `nil` clears the tint (`iconColor` removed); a palette name sets it — `applySymbol`'s sibling, /// one dimension over, exactly as `BoardInfoPopover`'s own `onSelectColor` writes the board's. private func applyIconColor(_ name: String?) { StyleCommand.apply( iconColor: name.map { StyleChange.set($0) } ?? .remove, to: Self.target(forCard: cardID), in: store, recents: recents, on: undo ) } } // MARK: - Actions /// The sidebar's **Actions** section, at the bottom of the stack (05-card-window.md ▸ Actions): /// **Delete** — moves the card to the trash — and **Reveal in Finder** — the card's folder. /// /// ### Delete writes; the window's dismissal is not its business /// /// The button calls `BoardStore.deleteCard`, which is the ⌫ delete exactly (same write op, same /// bracket, same stamps). It does not close this window: the card's move into `.trash/` rounds back through /// the watcher and `CardWindowHost.cardWindowFate` takes the window down, which is the same path a /// delete from the board — or from an agent — already takes. Dismissing from here as well would be a /// second rule able to disagree with the first, and 05's own wording is a sequence rather than a /// pair ("moves the card to the trash; the window then dismisses itself"). /// /// Recovery is the board's trash column, which is why this needs no confirmation: the card is still /// there to drag out or cut and paste back (03-board-ui.md § Trash — there is no Put Back), and 03 /// reserves the alert for the purge that isn't recoverable. /// /// ### Reveal is not edit-shaped /// /// So it stays enabled under the read-only lock, where Delete does not — inspection is a read (04 ▸ /// The trash's posture, shared by the trash row's own Reveal). What it reveals comes from /// `CardAttachments.revealURLs`, the same rule File ▸ Reveal in Finder's card-window scope answers /// through: this button is that rule's card-folder branch by construction, since it is the *card's* /// action rather than the attachment list's. struct CardActionsSection: View { let store: BoardStore let cardID: ItemID /// The card's own folder — `nil` only where the window has no board to build it from, which is a /// window on its way out. let cardFolder: URL? private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } private var revealURLs: [URL] { CardAttachments.revealURLs(cardFolder: cardFolder, selectedURL: nil, isSectionFocused: false) } var body: some View { VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) { CardSidebarSectionHeader(title: "Actions") // Delete above Reveal, which is the order 05 lists them in. Destructive styling, per 05 // — the one control in this window that takes the card away. Disabled under the // read-only lock like every other mutation (02-architecture.md's every-entry-point // predicate); that is the attachments section's `isEditable`, read from the store // directly because there is no handle to route it through here and nothing else in this // section that would want one. Button(role: .destructive) { store.deleteCard(cardID) } label: { // The width is the *label's*, not the button's: a bordered button sizes to its label, // so a frame around the button would centre a small pill in a wide row instead of // filling it. Both rows do it, so the two are one column rather than two widths. Text("Delete").frame(maxWidth: .infinity) } .tint(.red) .disabled(store.isReadOnly) Button { NSWorkspace.shared.activateFileViewerSelecting(revealURLs) } label: { Text("Reveal in Finder").frame(maxWidth: .infinity) } .disabled(revealURLs.isEmpty) } .buttonStyle(.bordered) .frame(maxWidth: .infinity, alignment: .leading) } }