The combo's trigger retires — each picker is one rectangle again, taller, and Style becomes Appearance in the sidebar

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
This commit is contained in:
2026-08-09 20:44:47 -04:00
parent 01aa9a0c94
commit 4210bbe0a7
17 changed files with 964 additions and 1321 deletions
+129 -132
View File
@@ -1,15 +1,20 @@
import AppKit
import SwiftUI
// MARK: - Style
// MARK: - Appearance
/// 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`).
/// 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
/// ### 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
@@ -17,18 +22,18 @@ import SwiftUI
/// 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
/// 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 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.
/// 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
///
@@ -43,14 +48,23 @@ import SwiftUI
///
/// ### 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.
/// `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
///
@@ -58,10 +72,10 @@ import SwiftUI
/// 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`).
/// 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
@@ -69,16 +83,10 @@ struct CardStyleSection: View {
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.
/// 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 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 }
@@ -96,102 +104,32 @@ struct CardStyleSection: View {
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.
///
/// **No explicit width since 2026-08-09** the combo used to be stretched to just over half the
/// row (`CardWindowMetrics.sidebarContentWidth(...) * 0.55`), which was the wide-bar shape the
/// owner's iteration undid. `ComboFieldMetrics` now carries its own taller, narrower intrinsic
/// width, so the row hands the combo no frame at all and lets it size itself; the leading
/// `Spacer(minLength: 8)` still pins it to the row's trailing edge.
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(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)
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 labeled **Symbol** row, below the background combo `backgroundComboRow`'s own
/// inspector-row shape, restated: caption leading, the combo trailing, **at the same size**.
///
/// That size 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 two rows need no frame of their own to agree both controls read
/// their geometry off the identical `ComboFieldMetrics`, `backgroundComboRow`'s own note.
private var symbolRow: some View {
HStack(spacing: 0) {
/// 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)
Spacer(minLength: 8)
SymbolPicker(
current: currentSymbol,
fallback: ItemSymbol.card,
@@ -202,18 +140,10 @@ struct CardStyleSection: View {
)
// 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`.
// 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)
}
.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
@@ -257,4 +187,71 @@ struct CardStyleSection: View {
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
}
}