The colour combo was a wide two-zone field with a second door onto the Colors panel; the symbol picker was a small square button with one. Both now subclass one `ComboFieldControl`, so they are the same width, height, radius and trigger by construction: click the face for the standalone picker, click the chevron for the quick list. The symbol face opens a new floating browser over the OS's own category, ordering and keyword plists out of CoreGlyphs.bundle — searchable, categorised, trademark-restricted glyphs withheld. The palette grows twelve to sixteen per table, filling the hue ring's four widest gaps with lime, jade, indigo and magenta at each table's own saturation and brightness. That gives the Style… popover's background grid a third row and the tint grid its third row of four, and both grids gain an Other… row onto the system colour picker — which the card sidebar's combo has had all along and the primary styling surface never did. An arbitrary hex already round-tripped; it is asserted now, including that an unquoted one is a YAML comment and no value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
331 lines
18 KiB
Swift
331 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 combo trailing, **at the same width**.
|
|
///
|
|
/// That width used to be the difference between the two rows. The picker was a 20pt bordered
|
|
/// square with one hit zone sitting under a wide two-zone colour combo, and this is the sidebar
|
|
/// where the mismatch was most visible — two adjacent rows setting two adjacent keys, looking
|
|
/// like different kinds of control. Since the 2026-08-09 rework `SymbolPicker` *is* a combo
|
|
/// (`ComboField.swift`), so the row hands it the identical `* 0.55` frame and the pair reads as
|
|
/// one inspector.
|
|
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) }
|
|
)
|
|
.frame(width: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize) * 0.55)
|
|
// 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)
|
|
}
|
|
}
|