Both the symbol picker and the background color control give up the two-zone combo chrome from the 2026-08-09 rework: no more face/trigger split, no more trailing chevron square. Each is now a single bordered rectangle with one hit zone, 2.1em tall and 1.5x that wide (a 4:6 ratio, 50% taller than the retired chrome's 18pt). A click anywhere opens the same curated popover the trigger used to gate. The background rectangle's popover is new: a None-plus-sixteen palette grid mirroring the Style… popover's own wells, with an Other… row onto the shared, pre-debounced Colors panel session — replacing the old NSMenu dropdown outright. The symbol rectangle keeps its existing popover (search, curated grid, tint colors, More Symbols…) verbatim; only its entry point collapsed to one zone. ComboFieldControl/ComboFieldMetrics (ComboField.swift) are replaced by PickerRectControl/PickerRectMetrics (PickerRect.swift). ColorComboView and its NSMenu-building pure model are retired wholesale in favor of ColorSwatchPicker. SymbolComboControl becomes SymbolGlyphControl. PaletteSwatch.rectImage, the last caller of which was the retired dropdown's menu rows, goes with it. In the card window sidebar, the "Style" section header becomes "Appearance" (sidebar only — the board context menu's Style… item and StyleEditorView's own naming are untouched), and the symbol and background controls move from stacked rows to side-by-side columns, each captioned above rather than leading. CardStyleSection no longer carries its own debounce Task for background panel picks — the shared Colors-panel session now delivers an already-settled value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
258 lines
15 KiB
Swift
258 lines
15 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - Appearance
|
|
|
|
/// The sidebar's **Appearance** section (renamed from **Style**, owner's ruling 2026-08-10, Pipeline
|
|
/// card 5004c540 — the sidebar-only rename; the board context menu's **Style…** item and
|
|
/// `StyleEditorView`'s own naming are unaffected, per that ruling's own scope): the **`SymbolPicker`**
|
|
/// rectangle beside the **`ColorSwatchPicker`** rectangle, side by side — Symbol left, Background
|
|
/// right, each captioned *above* rather than leading (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 — both
|
|
/// rectangles are the same reusable controls the board popover's glyph picker beside the rename field
|
|
/// already uses (`BoardInfoPopover`) and the Style… popover's own grid reuses (`ColorSwatchPicker`'s
|
|
/// own header).
|
|
///
|
|
/// ### Why the curated grid left (history, unaffected by this rename)
|
|
///
|
|
/// `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 rectangle 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 background rectangle has owned that half since 2026-08-06 (as `ColorComboView`, now
|
|
/// `ColorSwatchPicker`) — so the curated symbol grid was its only remaining job in this section, and
|
|
/// that job moved out to `SymbolPicker`. 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.
|
|
///
|
|
/// ### 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`/`onChange` 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). Nothing about `BoardStore.applyStyle`'s batch semantics, its no-op
|
|
/// skipping, or its `HistoryAnchor` choice moved; only the views that call it did —
|
|
/// `CardSessionUndoTests.sidebarStylingIsASessionGesture` calls that same funnel directly and needed
|
|
/// no change.
|
|
///
|
|
/// **The background rectangle's Colors-panel door no longer needs this section's own debounce.**
|
|
/// `ColorComboView`'s old panel handoff was a per-view-owned `SystemColorPanel`, undebounced at the
|
|
/// source, so this section carried its own trailing-debounce `Task` (`backgroundPanelCommit`) around
|
|
/// it. `ColorSwatchPicker`'s **Other…** row opens `SharedColorPanelSession` instead — the same
|
|
/// pre-debounced (~400ms) shared session the Style… popover's own background section and the symbol
|
|
/// popover's tint row already use — so the settled value arrives already debounced, and this section
|
|
/// only has to write it (`applyPanelBackground(_:)`), raw, never through the recents funnel, exactly
|
|
/// as `StyleEditorView.openBackgroundPanel` writes its own.
|
|
///
|
|
/// ### 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 and
|
|
/// `ColorSwatchPicker`'s own popover presentations are 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 rectangle below and
|
|
/// the symbol rectangle both take it as a parameter, for the same reason.
|
|
let undo: CardWindowUndo
|
|
|
|
/// 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: "Appearance")
|
|
// Side by side, Symbol left of Background — the owner's sketch (2026-08-10 ruling):
|
|
// "Symbol Background" over the two rectangles. `.top` alignment keeps the two
|
|
// captions, and the two rectangles below them, on one shared baseline even though
|
|
// neither column reserves a fixed width for the other.
|
|
HStack(alignment: .top, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
|
|
symbolColumn
|
|
backgroundColumn
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
// MARK: - Symbol
|
|
|
|
/// The **Symbol** column: its caption *above* the rectangle rather than leading it — the owner's
|
|
/// sketch, and `CardDetailsSection.row`'s own caption-over-value shape (`CardDetailsSection.
|
|
/// swift`) applied here instead of `CardSidebarSectionHeader`'s title-and-divider shape, which is
|
|
/// a section-level header rather than a per-control caption. `4` is `CardSidebarSectionHeader`'s
|
|
/// own literal spacing between its title and its divider, restated for the same small gap here.
|
|
private var symbolColumn: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Symbol")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
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, unchanged across the layout reshape and the
|
|
// control's own two-zone-to-one-zone change.
|
|
.disabled(!store.acceptsBoardMutations)
|
|
}
|
|
}
|
|
|
|
/// 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: - Background
|
|
|
|
/// The **Background** column, `symbolColumn`'s own caption-above shape mirrored — both
|
|
/// rectangles read their geometry off the identical `PickerRectMetrics`
|
|
/// (`PickerRect.swift`), so the two columns line up at the same height without either one
|
|
/// naming a size of its own.
|
|
private var backgroundColumn: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Background")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
ColorSwatchPicker(
|
|
role: .background,
|
|
value: currentBackground,
|
|
isEnabled: !store.isReadOnly,
|
|
onChange: { commitBackground($0) },
|
|
onPanelChange: { applyPanelBackground($0) }
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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: the grid's selection matching needs the bytes, not what they
|
|
/// render as (`ColorSwatchPicker`'s own header on why that matching is now literal-name only).
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
/// The Colors panel's settled value, from `ColorSwatchPicker`'s **Other…** door
|
|
/// (`SharedColorPanelSession`, already debounced ~400ms at the source — this section carries no
|
|
/// debounce of its own any more, `CardStyleSection`'s own header). Written raw, **never** through
|
|
/// `StyleCommand.apply` — a panel pick must not enter `StyleRecents`, the same rule
|
|
/// `StyleEditorView.openBackgroundPanel` applies to its own **Other…** row.
|
|
private func applyPanelBackground(_ newValue: String?) {
|
|
let target = Self.target(forCard: cardID)
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// One `styleSubjects` lookup behind `currentBackground`, `currentSymbol` and `currentIconColor`
|
|
/// above — 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
|
|
}
|
|
}
|