replace symbol grid in card window sidebar with a compact symbol picker
The card window sidebar's Style section embedded StyleEditorView symbols-only (showsBackground: false, showsSymbols: true), drawing the curated grid whole inside the sidebar's own scroll view — a permanently open 5-7 row grid ahead of every other section. Replace it with the reusable compact SymbolPicker (already used by BoardInfoPopover for the board's glyph): a single well at rest, the grid only inside its own popover. - CardStyleSection no longer instantiates StyleEditorView at all. A new "Symbol" row mirrors the existing "Background" row's inspector shape (caption leading, control trailing), wiring SymbolPicker's onSelect/ onSelectColor to icon/iconColor through StyleCommand.apply(...on: undo), the exact funnel the background combo already rides — so the card window's undo session semantics (13-native-undo.md) are unchanged. - The picker is fed CuratedSymbols.available rather than its own smaller general-purpose default, so a card's curated vocabulary doesn't shrink. - StyleEditorLayout.sidebar and showsBackground stay in StyleEditor.swift (still correct, still tested) rather than being cut as dead code — a separate, larger cleanup this card doesn't make (recorded on the card). - DESIGN/03-board-ui.md and DESIGN/05-card-window.md: updated the sentences describing the sidebar hosting the style editor's symbol grid to describe the compact picker instead. - Tests: CardSessionUndoTests gains a symbol/tint analogue of the existing background-combo session-routing test; CardSidebarTests gains a curated-set tripwire and an updated file-header note. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -3,19 +3,53 @@ import SwiftUI
|
||||
|
||||
// MARK: - Style
|
||||
|
||||
/// The sidebar's **Style** section: "the **embedded style editor** — background palette grid (with
|
||||
/// the leading None well) and curated symbol grid, per 03-board-ui.md ▸ Styling ▸ Controls. Card
|
||||
/// styling is discoverable here without a context menu; the same component appears in the board
|
||||
/// popover and behind Style…" (05-card-window.md ▸ 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`).
|
||||
///
|
||||
/// ### How this anchor differs from the other two — in one word, geometry
|
||||
/// ### Why the curated grid left
|
||||
///
|
||||
/// It hosts `StyleEditorView` itself, not a copy of it: the wells, the batch display, the arrow-key
|
||||
/// grammar, the read-only disabling, the recents the None well deliberately does not record, and the
|
||||
/// single `applyStyle` bracket every well's click rides are all the shared component's, identical
|
||||
/// here. The only thing this anchor supplies beyond a target is a `StyleEditorLayout` — the sidebar
|
||||
/// is narrower than the popover at every text size, so the grids fall in fewer columns and the
|
||||
/// symbol grid draws whole instead of scrolling inside the sidebar's own scroll view.
|
||||
/// `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.all` (~65, grouped by what a board item *is*
|
||||
/// — status/flow, containers, people, …) — `SymbolPicker.swift`'s own doc comment states why the two
|
||||
/// lists are free to diverge. A card is exactly the domain `CuratedSymbols` was grouped for, so this
|
||||
/// section passes it explicitly (`symbols: CuratedSymbols.available`) rather than taking the picker's
|
||||
/// smaller default: the Style… popover 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
|
||||
///
|
||||
@@ -23,10 +57,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). Which is why there is no
|
||||
/// session here to resolve, no popover to dismiss, and no way for a board-side selection change to
|
||||
/// re-aim the editor a card window is showing: the window's card is the target by construction, and
|
||||
/// when that card stops existing the window goes with it (`CardWindowFate`).
|
||||
/// 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
|
||||
@@ -34,9 +68,8 @@ 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 shared editor takes it as an
|
||||
/// anchor's parameter, exactly as it takes the layout — and so does the background combo below,
|
||||
/// for the same reason.
|
||||
/// 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`,
|
||||
@@ -54,7 +87,7 @@ struct CardStyleSection: View {
|
||||
/// 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 the editor's own batch machinery is a no-op here — one subject, so the display is
|
||||
/// 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])
|
||||
@@ -64,20 +97,7 @@ struct CardStyleSection: View {
|
||||
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
|
||||
CardSidebarSectionHeader(title: "Style")
|
||||
backgroundComboRow
|
||||
// Symbols only: the combo row above is this sidebar's whole background story
|
||||
// (03 ▸ Styling ▸ Controls, the 2026-08-06 anchor-ownership rule) — the well grid's
|
||||
// background half stays with the other anchors.
|
||||
StyleEditorView(
|
||||
store: store,
|
||||
recents: recents,
|
||||
target: Self.target(forCard: cardID),
|
||||
layout: .sidebar(
|
||||
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
|
||||
bodyPointSize: pointSize
|
||||
),
|
||||
undo: undo,
|
||||
showsBackground: false
|
||||
)
|
||||
symbolRow
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -112,8 +132,7 @@ struct CardStyleSection: View {
|
||||
/// string, never a resolved colour: `ColorComboModel`'s matching needs the bytes, not what they
|
||||
/// render as.
|
||||
private var currentBackground: String? {
|
||||
let subject = store.styleSubjects(of: Self.target(forCard: cardID)).first
|
||||
return StyleFieldState.written(subject?.background ?? .missing)
|
||||
StyleFieldState.written(currentSubject?.background ?? .missing)
|
||||
}
|
||||
|
||||
/// A discrete pick — commits immediately. `nil` removes; a name from `Palette.backgrounds` goes
|
||||
@@ -151,6 +170,85 @@ struct CardStyleSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.available,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user