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:
2026-08-08 22:14:40 -04:00
parent 898facebe0
commit 05ae703989
6 changed files with 216 additions and 44 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
### Controls (settled)
One **style editor** component — a background palette grid and a curated symbol grid — presented from two anchors: **embedded** in the card window sidebar's Style section (05-card-window.md), and as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't. **The anchors compose the halves they need** (2026-08-06): the card sidebar shows the symbol grid with the **color combo** (below) standing in for the background half the narrow context the combo was built for; the Style… popover carries both grids in full. (The board popover was briefly a third anchor — the background half embedded in its Theme tab — until the 2026-08-07 Theme rework made that tab preset-only; Style… with nothing selected is the board's manual grid now, and the popover's symbol picker beside the rename field still owns the board glyph.)
One **style editor** component — a background palette grid and a curated symbol grid — presented as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't. **The card window sidebar now styles through the compact `SymbolPicker` instead of this component** (2026-08-06 anchor-ownership rule for the background half; the symbol half followed the same way on 2026-08-08): the sidebar pairs the **color combo** (below) standing in for the background half, the narrow context the combo was built for — with the picker's own well and popover-hosted `CuratedSymbols` grid, so this component now has exactly one anchor left, the Style… popover, which alone carries both grids in full. (The board popover was briefly a third anchor — the background half embedded in its Theme tab — until the 2026-08-07 Theme rework made that tab preset-only; Style… with nothing selected is the board's manual grid now, and the popover's symbol picker beside the rename field still owns the board glyph — the same `SymbolPicker` the card sidebar now uses.)
- **Curated-first, panel-backed** (re-ratified 2026-08-06, reversing 2026-07-29's palette-only ruling): the background grid offers the 12 palette colors — every one AA-verified through one code path (ratified 2026-07-29: palette names route through the same runtime ink-selection seam as hand-written hex — the appearance flip picks the readable label vocabulary — and PaletteContrastTests pins that the chosen ink meets AA in both appearances for all 12 backgrounds, so palette drift can never silently break it) — plus a leading **None** well that removes the `background` key. Beside the grid the vocabulary now has a second, compact form: the **color combo** — a swatch-faced popup listing None, the role's twelve, the current off-palette value verbatim when there is one, and **Other…**, which opens the system Colors panel. The panel is the in-app escape hatch the 2026-07-29 ruling withheld: a pick landing exactly on a palette color stores the *name* (so a re-pick never drifts to a hex spelling), anything else stores the hex — the same unlimited vocabulary hand-editing always had, now pickable. An arbitrary pick changes no contrast story (it lands on the identical runtime ink computation hand-written hex already gets — 10-accessibility.md), and the quick-style recents stay palette-vocabulary: a panel pick never enters them.
- **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch. (Symbols keep this stance deliberately — the 2026-08-06 color-panel reversal above is colors only: the system offers a Colors panel worth deferring to, and no symbol browser of equal standing.)
+1 -1
View File
@@ -63,7 +63,7 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim
### Style
The card-level styling home: the **Background color combo** over the **curated symbol grid** (03-board-ui.md ▸ Styling ▸ Controls, its 2026-08-06 anchor-ownership rule) — the sidebar is exactly the narrow context the combo was built for, so it stands in for the well grid's background half here, panel escape hatch included; the full grid remains the surface at the other anchors. Card styling is discoverable here without a context menu; the component family is shared with the board popover and Style….
The card-level styling home: the **Background color combo** over the compact **`SymbolPicker`** (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) — the sidebar is exactly the narrow context the combo was built for, so it stands in for the style editor's background half here, panel escape hatch included, while the picker's own well and popover-hosted grid — fed `CuratedSymbols`, not the picker's smaller general-purpose default, so a card's curated vocabulary doesn't shrink — stands in for the symbol half the same way; the full style editor's grid remains the surface only at Style…. 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 uses.
### Details — unknown frontmatter keys
+133 -35
View File
@@ -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
+6 -4
View File
@@ -68,10 +68,12 @@ enum CardWindowMetrics {
/// What a sidebar *section* actually gets to lay out in: the column minus its two gutters.
///
/// Named because one section needs a number rather than a proposal the embedded style editor's
/// grids are a fixed count of fixed-size wells per row, and the count has to be decided before
/// the layout runs (`StyleEditorLayout.sidebar(contentWidth:)`). Everything else in the sidebar
/// simply fills what it is proposed and never asks.
/// Named because a section can need a number rather than a proposal originally the embedded
/// style editor's grids, a fixed count of fixed-size wells per row whose count had to be decided
/// before the layout ran (`StyleEditorLayout.sidebar(contentWidth:)`); the Style section's compact
/// `SymbolPicker` (2026-08-08) replaced that embed, but the Background color combo it sits beside
/// still sizes itself off this figure (`CardStyleSection.backgroundComboRow`). Everything else in
/// the sidebar simply fills what it is proposed and never asks.
static func sidebarContentWidth(bodyPointSize: CGFloat) -> CGFloat {
sidebarWidth(bodyPointSize: bodyPointSize) - 2 * gutter(bodyPointSize: bodyPointSize)
}
+52
View File
@@ -109,6 +109,19 @@ private func body(_ fixture: WriterFixture, _ path: String) throws -> String {
try FrontmatterDocument.parse(fixture.indexText(path)).body
}
/// A `StyleRecents` of this test's own `StyleRecentsTests`' own pattern (`StyleModelTests.swift`),
/// restated here rather than shared across files: the list is a real user preference, and a suite
/// that wrote into `UserDefaults.standard` would be editing the developer's own quick-style row.
@MainActor
private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) {
let name = "CardSessionUndoTests-\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else {
Issue.record("could not create a defaults suite")
return (StyleRecents(defaults: .standard), {})
}
return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) })
}
// MARK: - Routing
@MainActor
@@ -452,6 +465,45 @@ struct CardSessionCloseTests {
let document = try FrontmatterDocument.parse(fixture.indexText(cardPath))
#expect(document.background.isMissing, "the session's net effect, walked back")
}
/// The sidebar's compact `SymbolPicker` (2026-08-08, replacing the embedded curated grid) writes
/// through the same funnel the test above exercises for the background combo
/// `StyleCommand.apply`, exactly as `CardStyleSection.applySymbol`/`applyIconColor` call it so
/// this is that same claim, `icon`/`iconColor` instead of `background`. A control swap that left
/// this funnel unrouted would still look correct on screen (the well updates) right up until the
/// window closes and the change never reaches board history.
@Test("A symbol and tint change made in the window's sidebar joins the session, not the board")
func sidebarSymbolPickingIsASessionGesture() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
_ = try makeCommentBoard(fixture)
let window = try makeWindow(fixture)
let (recents, teardown) = makeRecents()
defer { teardown() }
StyleCommand.apply(
icon: .set("flame"),
iconColor: .set("carnation"),
to: CardStyleSection.target(forCard: cardID),
in: window.store,
recents: recents,
on: window.window
)
#expect(window.window.stack.undoActionName == "Restyle Card")
#expect(!window.board.canUndo)
// A symbol pick is not a background pick "only `.set` reaches `StyleRecents.record`" is
// `StyleCommand.apply`'s rule for `background` alone (`StyleEditor.swift`) so the picker's
// glyph and tint wells never touch the quick-style row.
#expect(recents.backgrounds.isEmpty)
await window.session.endSession()
#expect(window.board.undoActionName == coarseStep)
window.board.undo()
let document = try FrontmatterDocument.parse(fixture.indexText(cardPath))
#expect(document.icon.isMissing, "the session's net effect, walked back")
#expect(document.iconColor.isMissing, "and its tint with it")
}
}
// MARK: - Transactional staleness
+23 -3
View File
@@ -11,11 +11,16 @@ import Testing
/// - **Details** shows frontmatter the app deliberately does not understand, so nothing downstream
/// can notice when a key goes missing, arrives out of order, or renders as a YAML indicator. The
/// only check on it is a test that reads a file and says what the rows must be.
/// - **Style** embeds a shared, selection-aware component in a window that has no selection. An
/// anchor wired to the wrong target would look completely normal until it restyled something else.
/// - **Style** pins its own target this window's card, always, never the selection-aware target the
/// Style popover carries and (2026-08-08) hands the compact `SymbolPicker` the kanban-relevant
/// `CuratedSymbols` set rather than the picker's own smaller general-purpose default. A target wired
/// to the wrong card, or a curated list quietly narrowed back to the picker's default, would look
/// completely normal until it restyled the wrong card or hid a symbol the vocabulary used to offer.
/// - **The sidebar's geometry** is a grid of fixed-size wells in a column sized from font metrics:
/// at the wrong column count the last well in each row is simply unreachable, at no text size
/// anyone tests by eye.
/// anyone tests by eye. (The `StyleEditorLayout.sidebar` variant below now describes geometry no
/// view in the app currently requests the Style section's grid moved into `SymbolPicker`'s own
/// popover on 2026-08-08 but the pure function is still correct and still worth pinning.)
// MARK: - Details which keys
@@ -328,6 +333,21 @@ struct CardStyleAnchorTests {
let upper = ItemID(rawValue: Ident.card1.uppercased())
#expect(CardStyleSection.target(forCard: lower) == CardStyleSection.target(forCard: upper))
}
/// **The sidebar's `SymbolPicker` is fed `CuratedSymbols.available`, not the picker's own
/// smaller default** the design ruling behind the 2026-08-08 grid-to-picker swap ("so a card's
/// curated vocabulary doesn't shrink"). Not a test *of* the view (this file's own stance), but of
/// the two catalogs the ruling is a claim about: if `CuratedSymbols` ever shrank to
/// `SymbolPickerCatalog.defaultSet`'s size or below, the sidebar's explicit `symbols:` argument
/// would have quietly become a no-op, and this is the tripwire for that.
@Test("The card sidebar's curated symbol vocabulary is at least as wide as the picker's own default")
func theCuratedSetPassedToTheSidebarPickerDoesNotShrinkIt() {
#expect(CuratedSymbols.available.count > SymbolPickerCatalog.defaultSet.count)
// And it is still the same *available* set every other curated surface reads filtered
// through `ItemSymbol.exists` (`CuratedSymbols.available`'s own doc comment), never the
// unfiltered `all`, so the sidebar never offers a well this Mac cannot draw.
#expect(CuratedSymbols.available.allSatisfy(ItemSymbol.exists))
}
}
// MARK: - Style the anchor's geometry