Files
lanework/Kanban/UI/Card/CardSidebarSections.swift
T
rzen db863ba011 Three curated symbol vocabularies — boards, lanes, and cards each pick from their own shelf
`CuratedSymbols` was one flat ~65-glyph list serving every style-editor target alike. It is
now three level-specific sets — `boards` (project/container/identity), `lanes`
(workflow/stage/status), `cards` (work-item/content) — each ~30-40 entries, seeded from the
original list and `SymbolPickerCatalog.defaultSet`, reorganized by which level a glyph actually
reads as being about. Overlap is kept where a glyph genuinely fits everywhere (`flag`, `star`).

Wiring:
- `BoardInfoPopover`'s board-glyph `SymbolPicker` now passes `CuratedSymbols.availableBoards`
  instead of the picker's domain-agnostic default.
- The card sidebar's `SymbolPicker` (`CardSidebarSections`) now passes
  `CuratedSymbols.availableCards` instead of the old flat `available`.
- The style editor's own curated grid (`StyleEditorView`, the Style… popover's only remaining
  anchor) reads `CuratedSymbols.availableForStyleEditor(level:spansLevels:)`: a homogeneous
  target reads its own level's set, and a target that somehow spans more than one level (today
  unreachable — 04-interactions.md's cards-XOR-lanes rule keeps a live selection homogeneous)
  reads the three combined, via a new `BoardStore.styleTargetSpansLevels` seam that asks the
  question `styleLevel(of:)` deliberately collapses.
- `CuratedSymbols.combined` (the three sets' stable-order union) also replaces the old `.all`
  in `SymbolPickerCatalog`'s full-catalog fallback.
- `SymbolPickerCatalog.defaultSet` is kept as the fallback for a caller naming no level (a
  future saved-search picker, say) rather than retired.

DESIGN/03-board-ui.md and DESIGN/05-card-window.md's Styling/sidebar prose amended minimally
where they named "the curated set" as a single list.

Tests: three new/rewritten suites in KanbanTests/StyleModelTests.swift (set shape, availability,
overlap, `combined`, the style-editor level/span decision, the board-anchor width tripwire), one
new test in KanbanTests/StyleWriteTests.swift (`styleTargetSpansLevels`), and the old
single-list-pinning tests in KanbanTests/SymbolPickerTests.swift and KanbanTests/CardSidebarTests.swift
updated to the new set names. 2834 tests, 487 suites green (KanbanTests, arm64); one unrelated
flaky failure (RootRecoveryTests.vanishAndReturn under full-suite load) passed clean in isolation.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 01:26:36 -04:00

326 lines
18 KiB
Swift

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<Void, Never>?
/// 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)
}
}